@a11ign/screenreader-fleet 0.0.0-reserved.0 → 0.2.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 +142 -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 +85 -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,280 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ Unattended install for the local NVDA capture worker (Windows 11 ARM64 under QEMU).
4
+
5
+ Derived from the Autounattend.xml that UTM ships on utm-guest-tools.iso, which is
6
+ the proven ARM64 recipe: the LabConfig bypasses and the windowsPE ARM64 driver
7
+ paths come from there. What UTM's version does NOT do is complete the install
8
+ hands-off, so this adds:
9
+
10
+ * DiskConfiguration + ImageInstall - partition and install with no clicking
11
+ * LocalAccounts + AutoLogon - NVDA needs a logged-on interactive desktop,
12
+ so auto-logon is a REQUIREMENT here, not a
13
+ convenience: without it the worker is dead
14
+ after every reboot until a human logs in
15
+ * FirstLogonCommands - hand off to the worker bootstrap
16
+
17
+ Drive E: is the support ISO built by build-vm.sh (this file plus the
18
+ UTM guest-tools payload). Windows Setup picks autounattend.xml up automatically
19
+ from the root of removable media.
20
+
21
+ SECURITY: the account password below is in plain text, in a checked-in file. That is
22
+ acceptable ONLY because this is a disposable local VM behind QEMU user-mode
23
+ networking, reachable solely through the ports the launch script forwards to
24
+ 127.0.0.1. Do not put this VM on a bridged network, and do not reuse the password.
25
+
26
+ NOTE: UTM's template sets EnableLUA=false (UAC off). We deliberately do NOT copy
27
+ that. With UAC off every process runs at high integrity, and the capture pipeline
28
+ depends on NVDA (nvda_noUIAccess.exe) and Edge sitting at the SAME integrity level;
29
+ leaving UAC at its default keeps that relationship the way the Proxmox worker has it,
30
+ rather than betting the pipeline on an untested configuration. Provisioning gets the
31
+ elevation it needs from a RunLevel Highest scheduled task instead, which elevates
32
+ without a prompt - and a prompt would be fatal, since UAC dialogs render on the
33
+ secure desktop where automation cannot reach them.
34
+ -->
35
+ <unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
36
+
37
+ <settings pass="windowsPE">
38
+ <component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
39
+ <SetupUILanguage>
40
+ <UILanguage>en-US</UILanguage>
41
+ </SetupUILanguage>
42
+ <InputLocale>en-US</InputLocale>
43
+ <SystemLocale>en-US</SystemLocale>
44
+ <UILanguage>en-US</UILanguage>
45
+ <UserLocale>en-US</UserLocale>
46
+ </component>
47
+
48
+ <component name="Microsoft-Windows-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
49
+ <Diagnostics>
50
+ <OptIn>false</OptIn>
51
+ </Diagnostics>
52
+
53
+
54
+ <!-- UEFI/GPT layout: ESP, MSR, then Windows across the remainder. -->
55
+ <DiskConfiguration>
56
+ <WillShowUI>OnError</WillShowUI>
57
+ <Disk wcm:action="add">
58
+ <DiskID>0</DiskID>
59
+ <WillWipeDisk>true</WillWipeDisk>
60
+ <CreatePartitions>
61
+ <CreatePartition wcm:action="add">
62
+ <Order>1</Order>
63
+ <Type>EFI</Type>
64
+ <Size>300</Size>
65
+ </CreatePartition>
66
+ <CreatePartition wcm:action="add">
67
+ <Order>2</Order>
68
+ <Type>MSR</Type>
69
+ <Size>16</Size>
70
+ </CreatePartition>
71
+ <CreatePartition wcm:action="add">
72
+ <Order>3</Order>
73
+ <Type>Primary</Type>
74
+ <Extend>true</Extend>
75
+ </CreatePartition>
76
+ </CreatePartitions>
77
+ <ModifyPartitions>
78
+ <ModifyPartition wcm:action="add">
79
+ <Order>1</Order>
80
+ <PartitionID>1</PartitionID>
81
+ <Format>FAT32</Format>
82
+ <Label>System</Label>
83
+ </ModifyPartition>
84
+ <ModifyPartition wcm:action="add">
85
+ <Order>2</Order>
86
+ <PartitionID>2</PartitionID>
87
+ </ModifyPartition>
88
+ <ModifyPartition wcm:action="add">
89
+ <Order>3</Order>
90
+ <PartitionID>3</PartitionID>
91
+ <Format>NTFS</Format>
92
+ <Label>Windows</Label>
93
+ <Letter>C</Letter>
94
+ </ModifyPartition>
95
+ </ModifyPartitions>
96
+ </Disk>
97
+ </DiskConfiguration>
98
+
99
+ <!-- Match /IMAGE/NAME to the WIM's NAME, which is "Windows 11 Professional".
100
+ "Windows 11 Pro" is its DESCRIPTION, and matching that leaves Setup sitting on
101
+ the edition picker. Keep this block byte-for-byte simple: substituting
102
+ /IMAGE/INDEX here, or adding a comment inside <InstallFrom>, made Setup reject
103
+ the whole answer file and revert to a fully interactive install. -->
104
+ <ImageInstall>
105
+ <OSImage>
106
+ <InstallFrom>
107
+ <MetaData wcm:action="add">
108
+ <Key>/IMAGE/NAME</Key>
109
+ <Value>Windows 11 Professional</Value>
110
+ </MetaData>
111
+ </InstallFrom>
112
+ <InstallTo>
113
+ <DiskID>0</DiskID>
114
+ <PartitionID>3</PartitionID>
115
+ </InstallTo>
116
+ <WillShowUI>OnError</WillShowUI>
117
+ </OSImage>
118
+ </ImageInstall>
119
+
120
+ <!-- Windows 11 refuses to install without TPM 2.0 and Secure Boot. Rather than
121
+ wiring up swtpm, take UTM's approach and tell Setup to skip the checks. -->
122
+ <RunSynchronous>
123
+ <RunSynchronousCommand wcm:action="add">
124
+ <Order>1</Order>
125
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 0x00000001 /f</Path>
126
+ </RunSynchronousCommand>
127
+ <RunSynchronousCommand wcm:action="add">
128
+ <Order>2</Order>
129
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 0x00000001 /f</Path>
130
+ </RunSynchronousCommand>
131
+ <RunSynchronousCommand wcm:action="add">
132
+ <Order>3</Order>
133
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 0x00000001 /f</Path>
134
+ </RunSynchronousCommand>
135
+ <RunSynchronousCommand wcm:action="add">
136
+ <Order>4</Order>
137
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 0x00000001 /f</Path>
138
+ </RunSynchronousCommand>
139
+ <RunSynchronousCommand wcm:action="add">
140
+ <Order>5</Order>
141
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassDiskCheck /t REG_DWORD /d 0x00000001 /f</Path>
142
+ </RunSynchronousCommand>
143
+ </RunSynchronous>
144
+
145
+ <UserData>
146
+ <AcceptEula>true</AcceptEula>
147
+ <FullName>a11ign</FullName>
148
+ <Organization>a11ign</Organization>
149
+ <!-- Generic Windows 11 Pro KMS client key: selects the edition during an
150
+ unattended install. It does NOT activate, which is what we want - an
151
+ unactivated Windows is fully functional for capture, and leaving it
152
+ unactivated keeps the exported image portable between machines. -->
153
+ <ProductKey>
154
+ <Key>W269N-WFGWX-YVC9B-4J6C9-T83GX</Key>
155
+ <WillShowUI>Never</WillShowUI>
156
+ </ProductKey>
157
+ </UserData>
158
+ </component>
159
+
160
+ <!-- ARM64 virtio drivers, injected so the installed OS has storage, serial and
161
+ NETWORK working on first boot. Networking on first boot is what lets the
162
+ bootstrap fetch Node, Git and NVDA without any manual driver step.
163
+ Paths are UTM's, on the support ISO (drive E:). viogpu is deliberately left
164
+ out: UTM disables it because it causes a black screen. -->
165
+ <component name="Microsoft-Windows-PnpCustomizationsWinPE" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
166
+ <DriverPaths>
167
+ <PathAndCredentials wcm:action="add" wcm:keyValue="1">
168
+ <Path>E:\Drivers\vioscsi\w10\ARM64</Path>
169
+ </PathAndCredentials>
170
+ <PathAndCredentials wcm:action="add" wcm:keyValue="2">
171
+ <Path>E:\Drivers\viostor\w10\ARM64</Path>
172
+ </PathAndCredentials>
173
+ <PathAndCredentials wcm:action="add" wcm:keyValue="3">
174
+ <Path>E:\Drivers\vioserial\w10\ARM64</Path>
175
+ </PathAndCredentials>
176
+ <PathAndCredentials wcm:action="add" wcm:keyValue="4">
177
+ <Path>E:\Drivers\NetKVM\w10\ARM64</Path>
178
+ </PathAndCredentials>
179
+ <PathAndCredentials wcm:action="add" wcm:keyValue="5">
180
+ <Path>E:\Drivers\Balloon\w10\ARM64</Path>
181
+ </PathAndCredentials>
182
+ </DriverPaths>
183
+ </component>
184
+ </settings>
185
+
186
+ <settings pass="specialize">
187
+ <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
188
+ <ComputerName>A11Y-WORKER</ComputerName>
189
+ <TimeZone>GMT Standard Time</TimeZone>
190
+ </component>
191
+ <component name="Microsoft-Windows-Security-SPP-UX" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
192
+ <SkipAutoActivation>true</SkipAutoActivation>
193
+ </component>
194
+ <component name="Microsoft-Windows-SQMApi" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
195
+ <CEIPEnabled>0</CEIPEnabled>
196
+ </component>
197
+ </settings>
198
+
199
+ <settings pass="oobeSystem">
200
+ <component name="Microsoft-Windows-International-Core" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
201
+ <InputLocale>en-US</InputLocale>
202
+ <SystemLocale>en-US</SystemLocale>
203
+ <UILanguage>en-US</UILanguage>
204
+ <UserLocale>en-US</UserLocale>
205
+ </component>
206
+
207
+ <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
208
+ <UserAccounts>
209
+ <LocalAccounts>
210
+ <LocalAccount wcm:action="add">
211
+ <Name>witness</Name>
212
+ <DisplayName>witness</DisplayName>
213
+ <Group>Administrators</Group>
214
+ <Password>
215
+ <Value>witness</Value>
216
+ <PlainText>true</PlainText>
217
+ </Password>
218
+ </LocalAccount>
219
+ </LocalAccounts>
220
+ </UserAccounts>
221
+
222
+ <!-- The whole reason this file exists. NVDA is a GUI application: it needs a
223
+ real logged-on desktop session. No auto-logon means no session after a
224
+ reboot, which means captures silently return nothing at all. -->
225
+ <AutoLogon>
226
+ <Enabled>true</Enabled>
227
+ <Username>witness</Username>
228
+ <Password>
229
+ <Value>witness</Value>
230
+ <PlainText>true</PlainText>
231
+ </Password>
232
+ <LogonCount>2147483647</LogonCount>
233
+ </AutoLogon>
234
+
235
+ <OOBE>
236
+ <HideEULAPage>true</HideEULAPage>
237
+ <HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
238
+ <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
239
+ <HideLocalAccountScreen>true</HideLocalAccountScreen>
240
+ <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
241
+ <ProtectYourPC>3</ProtectYourPC>
242
+ <VMModeOptimizations>
243
+ <SkipWinREInitialization>true</SkipWinREInitialization>
244
+ </VMModeOptimizations>
245
+ </OOBE>
246
+
247
+ <FirstLogonCommands>
248
+ <SynchronousCommand wcm:action="add">
249
+ <Order>1</Order>
250
+ <Description>Disable hibernation</Description>
251
+ <CommandLine>cmd /c POWERCFG -H OFF</CommandLine>
252
+ </SynchronousCommand>
253
+ <!-- Adds qemu-ga, which gives the host a command channel into this guest over
254
+ QMP before SSH exists - the difference between debugging this VM and
255
+ guessing at it. /S is the NSIS silent switch. -->
256
+ <SynchronousCommand wcm:action="add">
257
+ <Order>2</Order>
258
+ <Description>Install UTM/virtio guest tools (adds qemu-ga)</Description>
259
+ <CommandLine>cmd /c for %i in (D E F G) do if exist %i:\utm-guest-tools.exe start /wait %i:\utm-guest-tools.exe /S</CommandLine>
260
+ </SynchronousCommand>
261
+ <!-- Never disable the screensaver's lock and then leave the console locked:
262
+ a locked desktop makes NVDA silent. Belt and braces alongside the
263
+ provisioning script, which also turns the screensaver off. -->
264
+ <SynchronousCommand wcm:action="add">
265
+ <Order>3</Order>
266
+ <Description>Disable screensaver (it steals foreground mid-capture)</Description>
267
+ <CommandLine>reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f</CommandLine>
268
+ </SynchronousCommand>
269
+ <!-- Provisioning needs elevation (firewall rules, HKLM Edge policy). A
270
+ RunLevel Highest scheduled task elevates with NO prompt; a UAC dialog here
271
+ would render on the secure desktop and hang the build forever. -->
272
+ <SynchronousCommand wcm:action="add">
273
+ <Order>4</Order>
274
+ <Description>Run the worker bootstrap elevated, without a UAC prompt</Description>
275
+ <CommandLine>cmd /c for %i in (D E F G) do if exist %i:\first-boot.cmd start /wait %i:\first-boot.cmd</CommandLine>
276
+ </SynchronousCommand>
277
+ </FirstLogonCommands>
278
+ </component>
279
+ </settings>
280
+ </unattend>
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env bash
2
+ # Build a local NVDA capture worker VM on Apple Silicon, unattended.
3
+ #
4
+ # ./packages/worker-fleet/src/local-worker/build-vm.sh /path/to/Win11_ARM64.iso
5
+ #
6
+ # Produces a self-contained VM directory (default ~/a11y-worker-vm) holding the disk
7
+ # image, UEFI vars and a run script. That directory IS the portable artifact: copy it
8
+ # to another Apple Silicon Mac, run ./run.sh, done. See docs/local-worker-vm.md.
9
+ #
10
+ # Why QEMU and not UTM: `utmctl` has no `create` subcommand, so a UTM-based flow can
11
+ # only ever be "click through these GUI steps". QEMU is scriptable end to end, which is
12
+ # the difference between a reproducible artifact and a folk recipe.
13
+ set -euo pipefail
14
+
15
+ # architecture-audit.md §8: builds a local UTM/QEMU worker VM, which is deprecated -- "The UTM is
16
+ # deprecated, that was a testing thing." (repository owner, 2026-09-05). Capture on the bare-metal fleet
17
+ # instead: npm run fleet:status, npm run fleet:deploy.
18
+ echo "DEPRECATED: build-vm.sh builds a local UTM worker VM. UTM was a testing path and is not the fleet." >&2
19
+ echo "Capture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy." >&2
20
+
21
+ # #636: a warning on a path nobody watches is a warning nobody reads -- so this REFUSES, rather than
22
+ # merely printing the two lines above and continuing. The measurements and reasoning behind the local-VM
23
+ # work stay in docs/local-worker-vm.md as history; only the path that runs is fenced.
24
+ if [ "${A11Y_LOCAL_VM:-}" != "1" ]; then
25
+ echo "refusing: set A11Y_LOCAL_VM=1 to run this deprecated local-VM script anyway." >&2
26
+ exit 1
27
+ fi
28
+
29
+ WIN_ISO="${1:-}"
30
+ VM_DIR="${A11Y_VM_DIR:-$HOME/a11y-worker-vm}"
31
+ DISK_GB="${A11Y_VM_DISK_GB:-64}"
32
+ RAM_MB="${A11Y_VM_RAM_MB:-8192}"
33
+ CPUS="${A11Y_VM_CPUS:-4}"
34
+ WORKER_PORT="${A11Y_PORT:-8765}"
35
+ SSH_PORT="${A11Y_VM_SSH_PORT:-2222}"
36
+
37
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
38
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
39
+
40
+ die() { echo "error: $*" >&2; exit 1; }
41
+ info() { echo "==> $*"; }
42
+
43
+ [ -n "$WIN_ISO" ] || die "usage: $0 <windows-11-arm64.iso> (build one with CrystalFetch, or packages/worker-fleet/src/local-worker/fetch-windows-iso.sh)"
44
+ [ -f "$WIN_ISO" ] || die "not found: $WIN_ISO"
45
+ command -v qemu-system-aarch64 >/dev/null || die "qemu missing: brew install qemu"
46
+ command -v qemu-img >/dev/null || die "qemu-img missing: brew install qemu"
47
+ command -v mkisofs >/dev/null || die "mkisofs missing: brew install cdrtools"
48
+
49
+ FW_CODE="$(ls /opt/homebrew/share/qemu/edk2-aarch64-code.fd /usr/local/share/qemu/edk2-aarch64-code.fd 2>/dev/null | head -1 || true)"
50
+ FW_VARS_SRC="$(ls /opt/homebrew/share/qemu/edk2-arm-vars.fd /usr/local/share/qemu/edk2-arm-vars.fd 2>/dev/null | head -1 || true)"
51
+ [ -n "$FW_CODE" ] || die "edk2-aarch64-code.fd not found (reinstall qemu)"
52
+ [ -n "$FW_VARS_SRC" ] || die "edk2-arm-vars.fd not found (reinstall qemu)"
53
+
54
+ mkdir -p "$VM_DIR"
55
+
56
+ # ---------------------------------------------------------------------------
57
+ info "Fetching UTM guest tools (ARM64 virtio drivers + qemu-ga)"
58
+ # These drivers are the reason this works at all: Windows 11 ARM64 has no inbox
59
+ # virtio-net driver, so without injecting NetKVM the installed OS has no network and
60
+ # cannot fetch Node/Git/NVDA. The ISO also carries qemu-ga, which gives the host a
61
+ # command channel into the guest over QMP before SSH exists.
62
+ GT_ISO="$VM_DIR/utm-guest-tools.iso"
63
+ if [ ! -f "$GT_ISO" ]; then
64
+ GT_URL="$(curl -sL https://api.github.com/repos/utmapp/qemu/releases/latest \
65
+ | grep -o 'https://[^"]*guest-tools[^"]*\.iso' | head -1 || true)"
66
+ [ -n "$GT_URL" ] || die "could not resolve the guest-tools ISO URL"
67
+ curl -L --fail -o "$GT_ISO" "$GT_URL"
68
+ fi
69
+ info "guest tools: $(du -h "$GT_ISO" | cut -f1)"
70
+
71
+ # ---------------------------------------------------------------------------
72
+ info "Building the support ISO (autounattend + drivers + bootstrap)"
73
+ # One removable medium carrying everything Setup and first logon need. Windows Setup
74
+ # picks autounattend.xml up automatically from the root of removable media.
75
+ STAGE="$(mktemp -d)"
76
+ trap 'rm -rf "$STAGE"' EXIT
77
+ MNT="$(mktemp -d)"
78
+ hdiutil attach -readonly -nobrowse -mountpoint "$MNT" "$GT_ISO" >/dev/null
79
+ cp -R "$MNT/Drivers" "$STAGE/"
80
+ cp "$MNT"/utm-guest-tools-*.exe "$STAGE/utm-guest-tools.exe"
81
+ hdiutil detach "$MNT" >/dev/null
82
+ rmdir "$MNT" 2>/dev/null || true
83
+
84
+ cp "$SCRIPT_DIR/autounattend.xml" "$STAGE/autounattend.xml"
85
+ cp "$SCRIPT_DIR/first-boot.cmd" "$STAGE/first-boot.cmd"
86
+ cp "$REPO_ROOT/scripts/bootstrap-windows-worker.ps1" "$STAGE/bootstrap-windows-worker.ps1"
87
+
88
+ SUPPORT_ISO="$VM_DIR/support.iso"
89
+ # -J (Joliet) so Windows sees real filenames rather than mangled 8.3 names.
90
+ mkisofs -quiet -J -R -V A11YSUPPORT -o "$SUPPORT_ISO" "$STAGE"
91
+ info "support ISO: $(du -h "$SUPPORT_ISO" | cut -f1)"
92
+
93
+ # ---------------------------------------------------------------------------
94
+ info "Creating disk and UEFI vars"
95
+ [ -f "$VM_DIR/disk.qcow2" ] || qemu-img create -f qcow2 "$VM_DIR/disk.qcow2" "${DISK_GB}G" >/dev/null
96
+ [ -f "$VM_DIR/efi-vars.fd" ] || cp "$FW_VARS_SRC" "$VM_DIR/efi-vars.fd"
97
+ # Symlink rather than copy: the Windows ISO is ~4 GB and only needed for --install.
98
+ # (When exporting the VM to another machine, the symlink is simply absent, which is
99
+ # fine -- normal boots do not use it.)
100
+ #
101
+ # Guard against the source ALREADY being $VM_DIR/windows.iso: `ln -sf x x` creates a
102
+ # symlink to itself and destroys the file. Passing the staged ISO back in is the obvious
103
+ # thing to do on a second run, so this must not eat it.
104
+ WIN_ISO_ABS="$(cd "$(dirname "$WIN_ISO")" && pwd)/$(basename "$WIN_ISO")"
105
+ if [ "$WIN_ISO_ABS" != "$VM_DIR/windows.iso" ]; then
106
+ ln -sf "$WIN_ISO_ABS" "$VM_DIR/windows.iso"
107
+ else
108
+ info "ISO is already staged at $VM_DIR/windows.iso; leaving it alone"
109
+ fi
110
+
111
+ # ---------------------------------------------------------------------------
112
+ info "Writing run.sh"
113
+ cat > "$VM_DIR/run.sh" <<RUNSH
114
+ #!/usr/bin/env bash
115
+ # Launch the a11ign NVDA worker VM. Generated by build-vm.sh.
116
+ # ./run.sh headless; worker on 127.0.0.1:${WORKER_PORT}, ssh on 2222
117
+ # ./run.sh --install first run: boot from the Windows ISO to install unattended
118
+ set -euo pipefail
119
+ VM_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
120
+ INSTALL=0
121
+ [ "\${1:-}" = "--install" ] && INSTALL=1
122
+
123
+ ARGS=(
124
+ -M virt,highmem=on
125
+ -accel hvf
126
+ -cpu host
127
+ -smp ${CPUS}
128
+ -m ${RAM_MB}
129
+ -drive if=pflash,format=raw,unit=0,readonly=on,file=${FW_CODE}
130
+ -drive if=pflash,format=raw,unit=1,file="\$VM_DIR/efi-vars.fd"
131
+ # NVMe rather than virtio-blk for the system disk: Windows 11 ARM64 has an inbox
132
+ # NVMe driver, so the disk is visible to Setup with no driver injection at all.
133
+ -drive file="\$VM_DIR/disk.qcow2",if=none,id=hd0,format=qcow2,cache=writeback,discard=unmap
134
+ -device nvme,drive=hd0,serial=a11yworker
135
+ # User-mode networking: the guest is NAT'd and unreachable except through these
136
+ # forwards, bound to loopback. That is what makes the plaintext password in
137
+ # autounattend.xml tolerable -- keep it that way, do not bridge this VM.
138
+ -netdev user,id=net0,hostfwd=tcp:127.0.0.1:${WORKER_PORT}-:${WORKER_PORT},hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22
139
+ -device virtio-net-pci,netdev=net0
140
+ -device virtio-gpu-pci
141
+ -device qemu-xhci
142
+ -device usb-kbd
143
+ -device usb-tablet
144
+ -device virtio-rng-pci
145
+ # The monitor socket is how you get EYES on a headless install:
146
+ # echo "screendump /tmp/vm.ppm" | socat - unix:\$VM_DIR/monitor.sock
147
+ -monitor unix:"\$VM_DIR/monitor.sock",server,nowait
148
+ -qmp unix:"\$VM_DIR/qmp.sock",server,nowait
149
+ -display none
150
+ -vnc 127.0.0.1:1
151
+ -rtc base=localtime
152
+ )
153
+
154
+ # CD-ROMs attach as USB storage, NOT with a bare "-drive media=cdrom". The aarch64
155
+ # "virt" machine has no IDE bus, so the bare form fails to start at all ("No 'ide' bus
156
+ # found"). virtio-blk would work for QEMU but Windows Setup cannot see a virtio disk
157
+ # without the driver it is trying to read FROM that disk; USB mass storage is inbox on
158
+ # Windows, so it sidesteps the chicken-and-egg entirely.
159
+ if [ \$INSTALL -eq 1 ]; then
160
+ ARGS+=(
161
+ -drive file="\$VM_DIR/windows.iso",if=none,id=cdwin,media=cdrom,readonly=on
162
+ -device usb-storage,drive=cdwin,removable=on,bootindex=0
163
+ -drive file="\$VM_DIR/support.iso",if=none,id=cdsup,media=cdrom,readonly=on
164
+ -device usb-storage,drive=cdsup,removable=on
165
+ )
166
+ else
167
+ # Keep the support ISO attached: provisioning may want the guest-tools installer,
168
+ # and an absent drive letter is a confusing failure mode.
169
+ ARGS+=(
170
+ -drive file="\$VM_DIR/support.iso",if=none,id=cdsup,media=cdrom,readonly=on
171
+ -device usb-storage,drive=cdsup,removable=on
172
+ )
173
+ fi
174
+
175
+ exec qemu-system-aarch64 "\${ARGS[@]}"
176
+ RUNSH
177
+ chmod +x "$VM_DIR/run.sh"
178
+
179
+ # Screenshot helper. The VM runs headless, so this is the only way to see what an
180
+ # unattended install is actually doing -- and "it is silently sitting on a prompt" and
181
+ # "it is working fine" look identical without it.
182
+ cat > "$VM_DIR/shot.sh" <<'SHOTSH'
183
+ #!/usr/bin/env bash
184
+ # Screenshot the headless guest. Usage: ./shot.sh [outfile.png]
185
+ set -euo pipefail
186
+ D="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
187
+ OUT="${1:-$D/shot.png}"
188
+ command -v socat >/dev/null || { echo "needs socat: brew install socat" >&2; exit 1; }
189
+ [ -S "$D/monitor.sock" ] || { echo "no monitor.sock -- is the VM running?" >&2; exit 1; }
190
+ echo "screendump $D/.shot.ppm" | socat - unix-connect:"$D/monitor.sock" >/dev/null 2>&1
191
+ sips -s format png "$D/.shot.ppm" --out "$OUT" >/dev/null 2>&1
192
+ echo "$OUT (disk now $(du -h "$D/disk.qcow2" | cut -f1))"
193
+ SHOTSH
194
+ chmod +x "$VM_DIR/shot.sh"
195
+
196
+ cat <<EOF
197
+
198
+ --- VM prepared in $VM_DIR ---
199
+
200
+ 1. Start the unattended install (takes ~20-40 min, no interaction):
201
+ $VM_DIR/run.sh --install
202
+
203
+ 2. Watch it without a display (screendump needs socat: brew install socat):
204
+ echo "screendump $VM_DIR/shot.ppm" | socat - unix:$VM_DIR/monitor.sock
205
+ # then convert/view: sips -s format png $VM_DIR/shot.ppm --out $VM_DIR/shot.png
206
+
207
+ 3. When it settles, the worker should answer on the host:
208
+ curl http://127.0.0.1:${WORKER_PORT}/health
209
+ ssh -p ${SSH_PORT} witness@127.0.0.1
210
+
211
+ 4. Point the control plane at it:
212
+ A11Y_WORKER=http://127.0.0.1:${WORKER_PORT} npm run witness -- https://example.com --task "..."
213
+
214
+ Subsequent boots: $VM_DIR/run.sh (no --install)
215
+
216
+ To hand this to someone else, copy the whole $VM_DIR directory. Shut the guest down
217
+ cleanly first, and see docs/local-worker-vm.md for shrinking it.
218
+ EOF
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env bash
2
+ # Clone the local NVDA worker VM into an additional, independent worker.
3
+ #
4
+ # ./packages/worker-fleet/src/local-worker/clone-worker.sh [new-name] # default: a11y-worker-2
5
+ #
6
+ # One worker serves one capture at a time by design (one desktop, one foreground window, one
7
+ # NVDA), so throughput scales by running more of them. On APFS the clone is copy-on-write, so
8
+ # it costs seconds and almost no disk until the two diverge.
9
+ #
10
+ # THE REASON THIS SCRIPT EXISTS: `utmctl clone` copies the MAC address. Two VMs with the same
11
+ # MAC on one vmnet share a single DHCP lease — the clone takes the original's IP and the
12
+ # original ends up with NO address at all. It boots perfectly and looks utterly dead from
13
+ # outside, which is a miserable thing to debug. So the clone gets a fresh MAC before it is
14
+ # ever started.
15
+ #
16
+ # Editing config.plist requires UTM to be quit: it caches configuration in memory and will
17
+ # write its cached copy back over the edit.
18
+ set -euo pipefail
19
+
20
+ # architecture-audit.md §8: clones a local UTM worker VM, which is deprecated -- "The UTM is deprecated,
21
+ # that was a testing thing." (repository owner, 2026-09-05). Capture on the bare-metal fleet instead:
22
+ # npm run fleet:status, npm run fleet:deploy.
23
+ echo "DEPRECATED: clone-worker.sh clones a local UTM worker VM. UTM was a testing path and is not the fleet." >&2
24
+ echo "Capture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy." >&2
25
+
26
+ # #636: a warning on a path nobody watches is a warning nobody reads -- so this REFUSES, rather than
27
+ # merely printing the two lines above and continuing. The measurements and reasoning behind the local-VM
28
+ # work stay in docs/local-worker-vm.md as history; only the path that runs is fenced.
29
+ if [ "${A11Y_LOCAL_VM:-}" != "1" ]; then
30
+ echo "refusing: set A11Y_LOCAL_VM=1 to run this deprecated local-VM script anyway." >&2
31
+ exit 1
32
+ fi
33
+
34
+ SOURCE_NAME="${A11Y_VM_NAME:-a11y-worker}"
35
+ NEW_NAME="${1:-${SOURCE_NAME}-2}"
36
+ DOCS="$HOME/Library/Containers/com.utmapp.UTM/Data/Documents"
37
+ BOOT_TIMEOUT_S=300
38
+ PORT="${A11Y_PORT:-8765}"
39
+
40
+ die() { echo "error: $*" >&2; exit 1; }
41
+ say() { echo "==> $*"; }
42
+
43
+ command -v utmctl >/dev/null || die "utmctl not found (brew install --cask utm)"
44
+ pgrep -x UTM >/dev/null || { say "launching UTM (utmctl needs the app)"; open -a UTM; sleep 5; }
45
+
46
+ uuid_of() { utmctl list | awk -v n="$1" '$3 == n { print $1 }'; }
47
+
48
+ SOURCE_UUID="$(uuid_of "$SOURCE_NAME")"
49
+ [ -n "$SOURCE_UUID" ] || die "no VM named '$SOURCE_NAME'"
50
+ [ "$(echo "$SOURCE_UUID" | wc -l | tr -d ' ')" -eq 1 ] || die "several VMs named '$SOURCE_NAME'; see docs/local-worker-vm.md"
51
+ [ -z "$(uuid_of "$NEW_NAME")" ] || die "'$NEW_NAME' already exists. Pick another name, or remove it in UTM first."
52
+
53
+ # Clone a stopped VM. Copying a running disk gives a crash-consistent image — the clone would
54
+ # boot as though the power had been pulled, with the dirty-volume repair that implies.
55
+ if [ "$(utmctl status "$SOURCE_UUID")" != "stopped" ]; then
56
+ say "stopping '$SOURCE_NAME' cleanly first (a clone of a running disk boots dirty)"
57
+ utmctl stop "$SOURCE_UUID" --request >/dev/null
58
+ for _ in $(seq 1 40); do
59
+ [ "$(utmctl status "$SOURCE_UUID")" = "stopped" ] && break
60
+ sleep 3
61
+ done
62
+ [ "$(utmctl status "$SOURCE_UUID")" = "stopped" ] || die "'$SOURCE_NAME' would not shut down; stop it by hand and re-run"
63
+ fi
64
+
65
+ say "cloning '$SOURCE_NAME' -> '$NEW_NAME'"
66
+ utmctl clone "$SOURCE_UUID" --name "$NEW_NAME" >/dev/null
67
+ NEW_UUID="$(uuid_of "$NEW_NAME")"
68
+ [ -n "$NEW_UUID" ] || die "clone reported success but '$NEW_NAME' is not registered"
69
+
70
+ CONFIG="$DOCS/$NEW_NAME.utm/config.plist"
71
+ [ -f "$CONFIG" ] || die "cloned bundle has no config.plist at $CONFIG"
72
+
73
+ # Every VM must be stopped before UTM is quit, not just the one being cloned.
74
+ #
75
+ # Quitting UTM tries to SUSPEND anything still running, and suspend fails outright with an
76
+ # emulated NVMe disk — which ours is. The result is a modal dialog ("Failed to save suspend
77
+ # state ... Quitting UTM will kill all running VMs") that blocks the script and, if dismissed
78
+ # with OK, takes down every running worker. Found by running this script while a second worker
79
+ # was serving.
80
+ say "stopping every running VM before quitting UTM (suspend fails on NVMe, and quitting kills running VMs)"
81
+ for uuid in $(utmctl list | awk 'NR > 1 && $2 != "stopped" { print $1 }'); do
82
+ utmctl stop "$uuid" --request >/dev/null 2>&1 || true
83
+ done
84
+ for _ in $(seq 1 40); do pgrep -f QEMULauncher >/dev/null || break; sleep 3; done
85
+ for uuid in $(utmctl list | awk 'NR > 1 && $2 != "stopped" { print $1 }'); do
86
+ utmctl stop "$uuid" >/dev/null 2>&1 || true
87
+ done
88
+ for _ in $(seq 1 10); do pgrep -f QEMULauncher >/dev/null || break; sleep 2; done
89
+ pgrep -f QEMULauncher >/dev/null && die "a VM is still running; stop it by hand before re-running"
90
+
91
+ say "quitting UTM so the config edit is not overwritten from its cache"
92
+ osascript -e 'tell application "UTM" to quit' 2>/dev/null || true
93
+ for _ in $(seq 1 10); do pgrep -x UTM >/dev/null || break; sleep 2; done
94
+
95
+ # Locally administered, unicast: first octet 0x52 has bit 1 set (local) and bit 0 clear
96
+ # (unicast), so it cannot collide with a real vendor's allocation.
97
+ NEW_MAC="$(python3 -c "
98
+ import random
99
+ print(':'.join(['52'] + ['%02X' % random.randint(0, 255) for _ in range(5)]))")"
100
+ cp "$CONFIG" "$CONFIG.bak"
101
+ plutil -replace Network.0.MacAddress -string "$NEW_MAC" "$CONFIG" \
102
+ || die "could not set the MAC; the original config is at $CONFIG.bak"
103
+ READBACK="$(plutil -extract Network.0.MacAddress raw "$CONFIG")"
104
+ [ "$READBACK" = "$NEW_MAC" ] || die "MAC did not take (read back '$READBACK'); config backed up at $CONFIG.bak"
105
+ say "new MAC $NEW_MAC (source keeps its own)"
106
+
107
+ open -a UTM
108
+ for _ in $(seq 1 15); do sleep 2; utmctl list >/dev/null 2>&1 && break; done
109
+ # #635: without this, a UTM that never becomes responsive falls straight through into the loop below,
110
+ # whose `utmctl list` would fail again, iterate zero names, and let the script finish having started
111
+ # nothing -- with no error anywhere. Refuse instead.
112
+ utmctl list >/dev/null 2>&1 || die "UTM did not become responsive after relaunch"
113
+
114
+ # Start the source first. Both guests carry the same Windows machine identity, and letting one
115
+ # settle before the other avoids two identical hostnames racing for the same DHCP server.
116
+ for name in $(utmctl list | awk -v n="$SOURCE_NAME" 'NR > 1 && $3 ~ "^"n { print $3 }' | sort -u); do
117
+ uuid="$(uuid_of "$name")"
118
+ say "starting '$name'"
119
+ utmctl start "$uuid" >/dev/null
120
+ waited=0
121
+ ip=""
122
+ while [ "$waited" -lt "$BOOT_TIMEOUT_S" ]; do
123
+ sleep 10; waited=$((waited + 10))
124
+ # Ignore link-local: a guest reports 169.254.x.x while DHCP is still pending, and that
125
+ # reads as a failure when it is just "not yet".
126
+ ip="$(utmctl ip-address "$uuid" 2>/dev/null | grep -oE '^[0-9.]+' | grep -v '^127' | grep -v '^169\.254' | head -1 || true)"
127
+ [ -n "$ip" ] && break
128
+ done
129
+ [ -n "$ip" ] || die "'$name' never got a DHCP lease in ${BOOT_TIMEOUT_S}s. Check its MAC is unique: plutil -extract Network.0.MacAddress raw '$DOCS/$name.utm/config.plist'"
130
+ health=""
131
+ while [ "$waited" -lt "$BOOT_TIMEOUT_S" ]; do
132
+ health="$(curl -s -m 5 "http://$ip:$PORT/health" 2>/dev/null || true)"
133
+ [ -n "$health" ] && break
134
+ sleep 10; waited=$((waited + 10))
135
+ done
136
+ say " $name $ip ${health:-NOT ANSWERING /health yet}"
137
+ done
138
+
139
+ echo
140
+ say "pool:"
141
+ ./"$(dirname "$0")/worker-ctl.sh" pool 2>/dev/null || echo " (run npm run worker:ctl -- pool)"