@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,202 @@
1
+ #!/usr/bin/env bash
2
+ # Create the NVDA worker VM in UTM, fully from the CLI. No GUI clicking.
3
+ #
4
+ # ./packages/worker-fleet/src/local-worker/create-utm-vm.sh <windows-arm64.iso> [support.iso]
5
+ #
6
+ # Why UTM rather than plain QEMU: homebrew QEMU + HVF cannot boot Windows 11 ARM64 on
7
+ # Apple Silicon (open upstream bug, https://gitlab.com/qemu-project/qemu/-/issues/2893,
8
+ # reproduced here across five machine configurations). UTM ships a QEMU patched for
9
+ # Windows-on-ARM, but it is a dlopen'd framework, not an executable, so it cannot be
10
+ # driven from a shell.
11
+ #
12
+ # The way in is UTM's scripting interface, which DOES support creation even though
13
+ # `utmctl` has no `create` subcommand:
14
+ # make new virtual machine with properties {backend:qemu, configuration:{...}}
15
+ # (see /Applications/UTM.app/Contents/Resources/UTM.sdef -- the `make` command takes a
16
+ # `qemu configuration` record).
17
+ set -euo pipefail
18
+
19
+ # architecture-audit.md §8: creates a local UTM worker VM, which is deprecated -- "The UTM is deprecated,
20
+ # that was a testing thing." (repository owner, 2026-09-05). Capture on the bare-metal fleet instead:
21
+ # npm run fleet:status, npm run fleet:deploy.
22
+ echo "DEPRECATED: create-utm-vm.sh creates a local UTM worker VM. UTM was a testing path and is not the fleet." >&2
23
+ echo "Capture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy." >&2
24
+
25
+ # #636: a warning on a path nobody watches is a warning nobody reads -- so this REFUSES, rather than
26
+ # merely printing the two lines above and continuing. The measurements and reasoning behind the local-VM
27
+ # work stay in docs/local-worker-vm.md as history; only the path that runs is fenced.
28
+ if [ "${A11Y_LOCAL_VM:-}" != "1" ]; then
29
+ echo "refusing: set A11Y_LOCAL_VM=1 to run this deprecated local-VM script anyway." >&2
30
+ exit 1
31
+ fi
32
+
33
+ WIN_ISO="${1:-}"
34
+ SUPPORT_ISO="${2:-$HOME/a11y-worker-vm/support.iso}"
35
+ VM_NAME="${A11Y_VM_NAME:-a11y-worker}"
36
+ # 4 GB: the documented Windows 11 minimum, and measured sufficient. Driving Edge and NVDA is
37
+ # not memory-intensive. Verified by running the same 10 cases on 4 GB and 8 GB VMs -- 165s vs
38
+ # 167s, byte-identical evidence (62 phrases, 51 role words, 22 heading-levels), and ZERO
39
+ # pagefile use on either guest, so nothing is being paged to fake the result.
40
+ #
41
+ # Do not size this from Windows' "in use" figure: that includes the file cache, which grows to
42
+ # fill whatever it is given. An 8 GB guest reported 3.5 GB "in use" and needed less than half
43
+ # of it. Committed bytes is the number that means anything.
44
+ RAM_MB="${A11Y_VM_RAM_MB:-4096}"
45
+ CPUS="${A11Y_VM_CPUS:-4}"
46
+ DISK_MB="${A11Y_VM_DISK_MB:-65536}"
47
+
48
+ die() { echo "error: $*" >&2; exit 1; }
49
+ info() { echo "==> $*"; }
50
+
51
+ [ -n "$WIN_ISO" ] || die "usage: $0 <windows-arm64.iso> [support.iso]"
52
+ [ -f "$WIN_ISO" ] || die "not found: $WIN_ISO"
53
+ [ -f "$SUPPORT_ISO" ] || die "support ISO not found: $SUPPORT_ISO (run build-vm.sh first)"
54
+ [ -d /Applications/UTM.app ] || die "UTM not installed: brew install --cask utm"
55
+
56
+ DOCS="$HOME/Library/Containers/com.utmapp.UTM/Data/Documents"
57
+ BUNDLE="$DOCS/$VM_NAME.utm"
58
+ [ -e "$BUNDLE" ] && die "$BUNDLE already exists. Remove it first: utmctl delete $VM_NAME"
59
+
60
+ # Refuse to create a SECOND registration with this name. Two registrations end up pointing
61
+ # at the same $VM_NAME.utm bundle, and then `utmctl delete` on EITHER of them removes that
62
+ # shared directory -- taking the other VM's disk and UEFI vars with it. That is not a
63
+ # name-resolution problem you can dodge by using UUIDs: it destroyed a fully provisioned
64
+ # worker here, and the only symptom afterwards is a start failing with
65
+ # 'The file "edk2-arm-vars.fd" doesn't exist'.
66
+ EXISTING_REG="$(utmctl list 2>/dev/null | awk -v n="$VM_NAME" '$3 == n { print $1 }' || true)"
67
+ if [ -n "$EXISTING_REG" ]; then
68
+ # Note we only get here when the bundle is ALREADY gone (checked just above), so this is
69
+ # an orphaned registration -- and `utmctl delete` on one of those fails with -2700 and
70
+ # leaves the entry behind. Editing UTM's registry is the route that works.
71
+ die "UTM already has a VM registered as '$VM_NAME', but its bundle is gone:
72
+ $EXISTING_REG
73
+ Clear the stale registration first -- see 'Never utmctl delete a VM whose name appears
74
+ twice' in docs/local-worker-vm.md for the PlistBuddy recipe. Do NOT use 'utmctl delete':
75
+ registrations sharing a name share one bundle, so it can destroy a working VM's disk.
76
+ Or pass A11Y_VM_NAME=<other> to build alongside the existing one."
77
+ fi
78
+
79
+ # UTM is sandboxed and will not follow a symlink out to /private/tmp, so give it real
80
+ # files in the user's home.
81
+ STAGE="$HOME/a11y-worker-vm"
82
+ mkdir -p "$STAGE"
83
+ if [ "$(cd "$(dirname "$WIN_ISO")" && pwd)/$(basename "$WIN_ISO")" != "$STAGE/windows.iso" ]; then
84
+ info "Staging the Windows ISO into $STAGE (UTM's sandbox cannot read /private/tmp)"
85
+ cp "$WIN_ISO" "$STAGE/windows.iso"
86
+ fi
87
+ [ -f "$STAGE/support.iso" ] || cp "$SUPPORT_ISO" "$STAGE/support.iso"
88
+
89
+ info "Launching UTM"
90
+ open -a UTM
91
+ for _ in $(seq 1 20); do pgrep -x UTM >/dev/null && break; sleep 1; done
92
+ pgrep -x UTM >/dev/null || die "UTM did not start"
93
+ sleep 3
94
+
95
+ info "Creating the VM via UTM's scripting interface"
96
+ # Three things here are load-bearing and were each found the hard way:
97
+ #
98
+ # 1. `displays` is NOT optional. Omit it and UTM builds the VM with no graphics adapter
99
+ # at all (`-vga none -nographic` in the launch line); Windows Setup cannot run
100
+ # without a framebuffer and the guest just sits there. virtio-ramfb is a plain UEFI
101
+ # framebuffer, which is what you want: Windows 11 ARM64 has no inbox virtio-gpu
102
+ # driver, and UTM disables the viogpu guest driver anyway (it causes a black screen).
103
+ #
104
+ # 2. DRIVE ORDER IS BOOT ORDER, and the SYSTEM DISK MUST COME FIRST. UTM assigns
105
+ # `bootindex` by position. Firmware skips the empty disk (no bootloader on it yet),
106
+ # boots the ISO, installs -- and then, crucially, Setup REBOOTS. If the ISO were
107
+ # first, that reboot starts the installer again from scratch, forever. With the disk
108
+ # first, the reboot finds the freshly written bootloader and Setup continues into its
109
+ # specialize/oobeSystem phases.
110
+ #
111
+ # Do not "fix" a boot failure by moving the ISO first: if firmware drops to the EDK2
112
+ # UEFI Shell with the disk first, the real cause is that the ISO has no valid UEFI
113
+ # El Torito record (see fetch-windows-iso.sh), not the ordering.
114
+ #
115
+ # 3. Do not try to drive that shell with `input keystroke`: it routes through the SPICE
116
+ # guest agent, which does not exist in UEFI, so the text silently never arrives
117
+ # (`input scan code` does reach the hardware, and is the only way to send keys before
118
+ # an OS is installed).
119
+ VM_ID="$(osascript <<APPLESCRIPT
120
+ tell application "UTM"
121
+ set vm to make new virtual machine with properties {backend:qemu, configuration:{name:"$VM_NAME", architecture:"aarch64", machine:"virt", memory:$RAM_MB, cpu cores:$CPUS, hypervisor:true, uefi:true, displays:{{hardware:"virtio-ramfb"}}, network interfaces:{{mode:shared}}, drives:{{guest size:$DISK_MB, interface:NVMe}, {source:POSIX file "$STAGE/windows.iso", interface:USB}, {source:POSIX file "$STAGE/support.iso", interface:USB}}}}
122
+ return id of vm
123
+ end tell
124
+ APPLESCRIPT
125
+ )"
126
+ [ -n "$VM_ID" ] || die "VM creation returned no id"
127
+ info "created $VM_NAME ($VM_ID)"
128
+
129
+ # UTM's scripting layer imports an ISO given as `source` by CONVERTING it to a qcow2
130
+ # and attaching it as a fixed Disk -- and `removable` is read-only in the sdef, so this
131
+ # cannot be expressed at creation time. Windows Setup will not read autounattend.xml off
132
+ # a fixed disk, and the windowsPE driver injection depends on that medium, so rewrite
133
+ # the two entries as real read-only CDs. UTM caches configs in memory, hence the quit.
134
+ #
135
+ # Keep such edits to values of keys UTM already wrote. UTM decodes config.plist with
136
+ # strict Swift Codable: one unexpected value and the VM silently DISAPPEARS from
137
+ # `utmctl list` with no error anywhere. (Learned by writing lowercase "linear" where UTM
138
+ # writes "Linear" for a display filter.) Anything structural should go through the
139
+ # scripting interface instead, which validates and fills defaults.
140
+ info "Rewriting the ISO drives as CD-ROMs (not expressible via scripting)"
141
+ osascript -e 'tell application "UTM" to quit' >/dev/null 2>&1 || true
142
+ for _ in $(seq 1 20); do pgrep -x UTM >/dev/null || break; sleep 1; done
143
+ # #635: the loop above exits EITHER because UTM quit OR because 20s ran out -- and the edit below only
144
+ # survives if UTM is actually gone (it caches config.plist in memory and would overwrite the edit on its
145
+ # own next flush). Refuse rather than silently proceeding into an edit that would be lost.
146
+ pgrep -x UTM >/dev/null && die "UTM did not quit; the config.plist edit below would be lost to its cache"
147
+ sleep 2
148
+
149
+ cp "$STAGE/windows.iso" "$BUNDLE/Data/windows.iso"
150
+ cp "$STAGE/support.iso" "$BUNDLE/Data/support.iso"
151
+ python3 - "$BUNDLE/config.plist" <<'PY'
152
+ import plistlib, sys
153
+ p = sys.argv[1]
154
+ d = plistlib.load(open(p, 'rb'))
155
+ for drive in d['Drive']:
156
+ name = drive.get('ImageName', '')
157
+ if name.startswith('windows'):
158
+ drive.update(ImageName='windows.iso', ImageType='CD', ReadOnly=True)
159
+ elif name.startswith('support'):
160
+ drive.update(ImageName='support.iso', ImageType='CD', ReadOnly=True)
161
+ plistlib.dump(d, open(p, 'wb'))
162
+ for drive in d['Drive']:
163
+ print(" %-12s %-6s %s" % (drive['ImageType'], drive['Interface'], drive['ImageName']))
164
+ PY
165
+ # Reclaim the qcow2 copies UTM made of both ISOs (~4.5 GB).
166
+ rm -f "$BUNDLE/Data/windows.qcow2" "$BUNDLE/Data/support.qcow2"
167
+
168
+ info "Starting the VM (Windows installs unattended from support.iso)"
169
+ open -a UTM
170
+ for _ in $(seq 1 20); do pgrep -x UTM >/dev/null && break; sleep 1; done
171
+ # #635: without this, a UTM that never relaunches falls straight through into `utmctl start`, which
172
+ # (per this file's own documented quirks) may report a misleading `unknown` state instead of a clear
173
+ # failure -- die here, with the actual cause, instead.
174
+ pgrep -x UTM >/dev/null || die "UTM did not relaunch before starting the VM"
175
+ sleep 3
176
+ # Operate by UUID, never by name. If two registrations ever share a name, `utmctl start
177
+ # <name>` silently picks the wrong one -- and `utmctl delete <name>` will remove the
178
+ # SHARED bundle directory, destroying the other VM's disk with it.
179
+ utmctl start "$VM_ID"
180
+ utmctl status "$VM_ID"
181
+
182
+ DISK="$(ls "$BUNDLE/Data"/*.qcow2 | head -1)"
183
+ cat <<EOF
184
+
185
+ --- $VM_NAME started ---
186
+
187
+ The install is unattended. There is no console screenshot from the CLI, so track it by
188
+ side effect -- Windows only writes to the system disk once Setup is really running:
189
+
190
+ watch: du -h "$DISK"
191
+ (WinPE decompresses boot.wim into RAM first, so expect a few minutes at 0 growth)
192
+
193
+ When it is up:
194
+ utmctl query ip $VM_ID # needs qemu-ga, installed at first logon
195
+ curl http://<guest-ip>:8765/health
196
+ A11Y_WORKER=http://<guest-ip>:8765 npm run witness -- https://example.com --task "..."
197
+
198
+ Guest commands without SSH (qemu-ga, via UTM):
199
+ utmctl exec $VM_ID --cmd "powershell -Command Get-Process node"
200
+
201
+ Troubleshooting the worker itself: docs/nvda-worker-runbook.md
202
+ EOF
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env bash
2
+ # Build an official Windows 11 ARM64 ISO on macOS, from the CLI.
3
+ #
4
+ # ./packages/worker-fleet/src/local-worker/fetch-windows-iso.sh [outdir]
5
+ #
6
+ # Microsoft's ARM64 ISO download is a session-token web flow that does not script
7
+ # cleanly, so this uses UUP dump: it fetches the same Unified Update Platform packages
8
+ # from Microsoft's update servers and assembles them locally. The bits are official;
9
+ # the assembly is local.
10
+ #
11
+ # The conversion needs five tools. Four are in homebrew-core. `chntpw` is NOT, and the
12
+ # only convenient macOS build is the universal one inside CrystalFetch.app -- which is
13
+ # signed against that bundle, so invoking it directly dies with SIGTRAP (exit 133)
14
+ # unless the bundle's OpenSSL.framework is on the framework path. Hence the shim below.
15
+ set -euo pipefail
16
+
17
+ # architecture-audit.md §8: builds an ISO for a local UTM worker VM, which is deprecated -- "The UTM is
18
+ # deprecated, that was a testing thing." (repository owner, 2026-09-05). Bare-metal boxes install via
19
+ # PXE/autounattend.xml instead — see packages/worker-fleet/src/provisioning/bare-metal/.
20
+ echo "DEPRECATED: fetch-windows-iso.sh feeds a local UTM worker VM build. UTM was a testing path and is not the fleet." >&2
21
+ echo "Capture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy." >&2
22
+
23
+ # #636: a warning on a path nobody watches is a warning nobody reads -- so this REFUSES, rather than
24
+ # merely printing the two lines above and continuing. The measurements and reasoning behind the local-VM
25
+ # work stay in docs/local-worker-vm.md as history; only the path that runs is fenced.
26
+ if [ "${A11Y_LOCAL_VM:-}" != "1" ]; then
27
+ echo "refusing: set A11Y_LOCAL_VM=1 to run this deprecated local-VM script anyway." >&2
28
+ exit 1
29
+ fi
30
+
31
+ OUT_DIR="${1:-$HOME/a11y-worker-vm/iso-build}"
32
+ EDITION="${A11Y_WIN_EDITION:-professional}"
33
+ LANG_CODE="${A11Y_WIN_LANG:-en-us}"
34
+
35
+ # Default to 23H2, NOT the newest build. Windows 11 24H2 replaced Setup with one that
36
+ # calls SetupPrep.exe, is far stricter about autounattend.xml, and frequently ignores it
37
+ # outright -- we saw the install boot fine and then stop dead on the language/region
38
+ # prompt, which is the symptom widely reported by others. 23H2 uses the classic Setup and
39
+ # honours the unattend file. Override with A11Y_WIN_VERSION if you want to retest 24H2.
40
+ WIN_VERSION="${A11Y_WIN_VERSION:-23H2}"
41
+
42
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
43
+
44
+ die() { echo "error: $*" >&2; exit 1; }
45
+ info() { echo "==> $*"; }
46
+
47
+ command -v brew >/dev/null || die "homebrew required"
48
+
49
+ info "Installing conversion tools"
50
+ for f in aria2 cabextract wimlib cdrtools; do
51
+ brew list --formula "$f" >/dev/null 2>&1 || brew install "$f"
52
+ done
53
+
54
+ CF_BIN="/Applications/CrystalFetch.app/Contents/MacOS/chntpw"
55
+ CF_FW="/Applications/CrystalFetch.app/Contents/Frameworks"
56
+ if [ ! -x "$CF_BIN" ]; then
57
+ info "chntpw not found; installing CrystalFetch (it bundles a universal build)"
58
+ brew install --cask crystalfetch
59
+ fi
60
+ [ -x "$CF_BIN" ] || die "chntpw unavailable and CrystalFetch install failed"
61
+
62
+ SHIM_DIR="$OUT_DIR/.tools"
63
+ mkdir -p "$SHIM_DIR"
64
+ cat > "$SHIM_DIR/chntpw" <<SHIM
65
+ #!/bin/bash
66
+ exec env DYLD_FRAMEWORK_PATH="$CF_FW" "$CF_BIN" "\$@"
67
+ SHIM
68
+ chmod +x "$SHIM_DIR/chntpw"
69
+ export PATH="$SHIM_DIR:$PATH"
70
+
71
+ for t in aria2c cabextract wimlib-imagex chntpw mkisofs; do
72
+ command -v "$t" >/dev/null || die "missing tool: $t"
73
+ done
74
+ info "toolchain ready"
75
+
76
+ info "Finding the newest RETAIL Windows 11 ARM64 build for $WIN_VERSION"
77
+ # The listing includes Insider Preview builds; filter to released versions so the worker
78
+ # is not sitting on a preview OS. Titles look like "Windows 11, version 23H2".
79
+ UUID="$(curl -sL --fail "https://api.uupdump.net/listid.php?search=windows%2011&sortByDate=1" \
80
+ | WANT="$WIN_VERSION" node -e '
81
+ let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{
82
+ const want = (process.env.WANT || "").toLowerCase();
83
+ const builds = JSON.parse(s)?.response?.builds || {};
84
+ const rows = Object.values(builds).filter(b =>
85
+ /arm64/i.test(b.arch || "") &&
86
+ /^Windows 11, version/i.test(b.title || "") &&
87
+ (b.title || "").toLowerCase().includes("version " + want));
88
+ if (!rows.length) { process.exit(1); }
89
+ console.log(rows[0].uuid);
90
+ });')"
91
+ [ -n "$UUID" ] || die "could not find a retail Windows 11 ARM64 $WIN_VERSION build"
92
+ info "build uuid: $UUID"
93
+
94
+ mkdir -p "$OUT_DIR"
95
+ cd "$OUT_DIR"
96
+
97
+ # Idempotence: if a finished ISO with a UEFI boot record is already here, stop. And if the
98
+ # converter's output is present, skip straight to the rebuild rather than re-downloading
99
+ # ~10 GB. Re-running this script should cost minutes, not an hour.
100
+ EXISTING="$(ls -t "$OUT_DIR"/*.ISO "$OUT_DIR"/*.iso 2>/dev/null | grep -vi support | head -1 || true)"
101
+ if [ -n "$EXISTING" ] && xorriso -indev "$EXISTING" -report_el_torito plain 2>/dev/null | grep -q UEFI; then
102
+ echo "ISO ready (already built): $EXISTING"
103
+ echo "Next: ./packages/worker-fleet/src/local-worker/create-utm-vm.sh \"$EXISTING\""
104
+ exit 0
105
+ fi
106
+ if [ -n "$EXISTING" ]; then
107
+ info "found an existing converter ISO; skipping download and going straight to the rebuild"
108
+ SKIP_DOWNLOAD=1
109
+ fi
110
+
111
+ info "Fetching the UUP download+convert pack"
112
+ # autodl=2 is "download using aria2 and convert". autodl=3 is the virtual-editions
113
+ # flow and errors out unless you request extra editions.
114
+ curl -sL --fail -o pack.zip \
115
+ "https://uupdump.net/get.php?id=${UUID}&pack=${LANG_CODE}&edition=${EDITION}&autodl=2"
116
+ unzip -o -q pack.zip
117
+ chmod +x uup_download_macos.sh
118
+
119
+ # Soften aria2's fan-out before running it. The stock script uses -x16 -s16 across many
120
+ # hosts, which overwhelmed a resolver mid-download here: 42 x errorCode=19 ("Name
121
+ # resolution ... failed") and the run aborted twice with ~7 GB still to fetch. Fewer
122
+ # connections plus explicit resolvers and unlimited retries turns a DNS blip into a pause
123
+ # instead of a failure. Costs nothing on a healthy link; rescues a flaky one.
124
+ GENTLE='-x4 -s4 -j2 --async-dns-server=1.1.1.1,8.8.8.8,9.9.9.9 --max-tries=0 --retry-wait=5 --connect-timeout=30 --timeout=60'
125
+ sed -i '' "s/-x16 -s16 -j5/$GENTLE/; s/-x16 -s16 -j2/$GENTLE/" uup_download_macos.sh
126
+ grep -q 'async-dns-server' uup_download_macos.sh && info "aria2 settings softened for flaky links"
127
+
128
+ if [ "${SKIP_DOWNLOAD:-0}" = "1" ]; then
129
+ info "skipping download/convert (existing ISO found)"
130
+ else
131
+ info "Downloading (~10 GB) and converting. This is the slow part."
132
+ # aria2 resumes, so re-running after an interruption picks up where it left off.
133
+ ./uup_download_macos.sh
134
+ fi
135
+
136
+ ISO="$(ls -t "$OUT_DIR"/*.ISO "$OUT_DIR"/*.iso 2>/dev/null | grep -vi 'support\|fixed' | head -1 || true)"
137
+ [ -n "$ISO" ] || die "conversion finished but no ISO was produced (see the log in $OUT_DIR)"
138
+ info "converter produced: $ISO"
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # The converter's ISO is NOT UEFI-bootable. Its arm64 branch runs
142
+ # mkisofs -b efi/microsoft/boot/efisys.bin --no-emul-boot ...
143
+ # and `-b` registers a BIOS boot image (platform 0x00). A UEFI entry needs platform
144
+ # 0xEF, so firmware finds no bootable UEFI record, silently declines the disc, and drops
145
+ # to the EDK2 UEFI Shell -- which looks exactly like a hung VM. Confirm with:
146
+ # xorriso -indev <iso> -report_el_torito plain # want "Pltf: UEFI", not BIOS
147
+ #
148
+ # While rebuilding we also swap efisys.bin for efisys_noprompt.bin. The default image
149
+ # stops at "Press any key to boot from CD or DVD" and gives up if nobody presses one,
150
+ # which is fatal for an unattended install.
151
+ #
152
+ # NOTE: xorriso cannot do this job -- `xorriso -as mkisofs` rejects `-udf`, and UDF is
153
+ # required because install.wim exceeds 4 GB. cdrtools mkisofs supports both --udf and
154
+ # -eltorito-platform, so it is the builder here.
155
+ if xorriso -indev "$ISO" -report_el_torito plain 2>/dev/null | grep -q "UEFI"; then
156
+ info "ISO already has a UEFI El Torito record; leaving it alone"
157
+ else
158
+ info "Rebuilding the ISO with a UEFI El Torito record (the converter's is BIOS-only)"
159
+ STAGE_DIR="$OUT_DIR/isodir"
160
+ MNT="$OUT_DIR/mnt"
161
+ # ditto preserves the ISO's read-only bits, so a previous staging tree cannot be
162
+ # removed (or modified) without restoring write permission first -- otherwise a re-run
163
+ # dies on "rm: ...: Permission denied" and leaves a half-deleted tree behind.
164
+ [ -d "$STAGE_DIR" ] && chmod -R u+w "$STAGE_DIR" 2>/dev/null || true
165
+ rm -rf "$STAGE_DIR" "$MNT"; mkdir -p "$STAGE_DIR" "$MNT"
166
+ # Must copy via a macOS UDF mount: the converter passes `--hide "*"`, so every file
167
+ # lives ONLY in the UDF tree and an ISO9660 reader (including xorriso) sees an empty
168
+ # disc. boot.catalog is unreadable and is regenerated by mkisofs anyway.
169
+ hdiutil attach -readonly -nobrowse -mountpoint "$MNT" "$ISO" >/dev/null
170
+ ditto --norsrc --noextattr "$MNT" "$STAGE_DIR" 2>/dev/null || true
171
+ hdiutil detach "$MNT" >/dev/null
172
+ rmdir "$MNT" 2>/dev/null || true
173
+ # Same reason: the staged copy is read-only, and we need to write autounattend.xml into
174
+ # it and update boot.wim.
175
+ chmod -R u+w "$STAGE_DIR"
176
+ [ -f "$STAGE_DIR/sources/boot.wim" ] || die "extraction failed (no sources/boot.wim)"
177
+
178
+ UNATTEND="$SCRIPT_DIR/autounattend.xml"
179
+
180
+ # GUARD 1: language must match the media. The answer file's SetupUILanguage/UILanguage
181
+ # must name a language pack that actually exists in sources/. Ask for en-GB on an EN-US
182
+ # disc and Setup cannot load its UI language, silently abandons the whole answer file,
183
+ # and shows the interactive language prompt -- indistinguishable from "file not found".
184
+ # This one cost hours; it is two lines to check.
185
+ MEDIA_LANG="$(ls "$STAGE_DIR/sources" | grep -oiE '^[a-z]{2}-[a-z]{2}$' | head -1)"
186
+ [ -n "$MEDIA_LANG" ] || die "could not determine the media language from sources/"
187
+ WANTED_LANGS="$(grep -oE '<(SetupUILanguage>)?[[:space:]]*<?UILanguage>[a-zA-Z-]+' "$UNATTEND" \
188
+ | grep -oE '[a-z]{2}-[A-Z]{2}' | sort -u | tr '\n' ' ')"
189
+ for l in $WANTED_LANGS; do
190
+ if [ "$(echo "$l" | tr 'A-Z' 'a-z')" != "$(echo "$MEDIA_LANG" | tr 'A-Z' 'a-z')" ]; then
191
+ die "autounattend.xml asks for UI language '$l' but the media only has '$MEDIA_LANG'. Setup will ignore the answer file. Fix the locales in $UNATTEND."
192
+ fi
193
+ done
194
+ info "language check: unattend wants [$WANTED_LANGS], media has [$MEDIA_LANG]"
195
+
196
+ # GUARD 2: the image name must match the WIM's NAME (not its DESCRIPTION). Getting this
197
+ # wrong does not break the answer file -- Setup just stops on the edition picker.
198
+ if command -v wimlib-imagex >/dev/null; then
199
+ WIM_NAME="$(wimlib-imagex info "$STAGE_DIR/sources/install.wim" 2>/dev/null \
200
+ | awk -F': *' '/^Name:/{print $2; exit}')"
201
+ WANT_NAME="$(grep -A2 'IMAGE/NAME' "$UNATTEND" | grep -oE '<Value>[^<]+' | sed 's/<Value>//' | head -1)"
202
+ if [ -n "$WIM_NAME" ] && [ -n "$WANT_NAME" ] && [ "$WIM_NAME" != "$WANT_NAME" ]; then
203
+ die "autounattend.xml installs image '$WANT_NAME' but the WIM's Name is '$WIM_NAME' (its Description may differ). Fix /IMAGE/NAME in $UNATTEND."
204
+ fi
205
+ info "image check: unattend wants '$WANT_NAME', WIM Name is '$WIM_NAME'"
206
+ fi
207
+
208
+ # autounattend.xml belongs on the ROOT OF THE INSTALL MEDIA, which is where Setup
209
+ # looks first. It is also on the support ISO; both is harmless and more robust.
210
+ cp "$UNATTEND" "$STAGE_DIR/autounattend.xml"
211
+
212
+ # Belt and braces: also inject it into boot.wim's Setup image at \Windows\Panther,
213
+ # which Setup reads explicitly. Removable-media discovery is not guaranteed when the
214
+ # disc is presented as USB mass storage rather than a real DVD, as it is here.
215
+ if command -v wimlib-imagex >/dev/null; then
216
+ chmod u+w "$STAGE_DIR/sources/boot.wim"
217
+ printf 'delete --force /Windows/Panther/unattend.xml\nadd "%s" /Windows/Panther/unattend.xml\n' \
218
+ "$UNATTEND" | wimlib-imagex update "$STAGE_DIR/sources/boot.wim" 2 >/dev/null 2>&1 \
219
+ && info "injected unattend into boot.wim (index 2, Windows Setup)"
220
+ fi
221
+
222
+ FIXED="$OUT_DIR/windows-arm64-uefi.iso"
223
+ LABEL="$(basename "$ISO" | sed 's/\.[Ii][Ss][Oo]$//' | cut -c1-32)"
224
+ rm -f "$FIXED"
225
+ mkisofs -eltorito-platform efi -b "efi/microsoft/boot/efisys_noprompt.bin" \
226
+ --no-emul-boot --udf -iso-level 3 --hide "*" -V "$LABEL" -o "$FIXED" "$STAGE_DIR"
227
+ xorriso -indev "$FIXED" -report_el_torito plain 2>/dev/null | grep -q "UEFI" \
228
+ || die "rebuild still has no UEFI El Torito record"
229
+ chmod -R u+w "$STAGE_DIR" 2>/dev/null || true
230
+ rm -rf "$STAGE_DIR"
231
+ ISO="$FIXED"
232
+ info "rebuilt: $ISO"
233
+ fi
234
+
235
+ echo
236
+ echo "ISO ready: $ISO"
237
+ echo "Next: ./packages/worker-fleet/src/local-worker/build-vm.sh \"$ISO\""
238
+ echo " ./packages/worker-fleet/src/local-worker/create-utm-vm.sh \"$ISO\""
@@ -0,0 +1,58 @@
1
+ @echo off
2
+ rem Runs from the support ISO at first logon (invoked by autounattend.xml).
3
+ rem
4
+ rem Its only job: get an ELEVATED shell without a UAC prompt, then run the worker
5
+ rem bootstrap. A UAC prompt here would be fatal -- it renders on the secure desktop,
6
+ rem which no automation can click and NVDA cannot read.
7
+ setlocal EnableDelayedExpansion
8
+ set "LOG=C:\a11y-first-boot.log"
9
+ echo [%DATE% %TIME%] first-boot starting from %~dp0 > "%LOG%"
10
+
11
+ rem The NetKVM driver was injected during windowsPE so the NIC exists, but DHCP may
12
+ rem not have finished by first logon. The bootstrap needs the network for winget.
13
+ set NET=0
14
+ for /L %%i in (1,1,60) do (
15
+ if !NET!==0 (
16
+ ping -n 1 -w 1000 8.8.8.8 >nul 2>&1 && set NET=1
17
+ if !NET!==0 timeout /t 2 /nobreak >nul
18
+ )
19
+ )
20
+ if !NET!==1 (echo [%DATE% %TIME%] network up >> "%LOG%") else (echo [%DATE% %TIME%] WARNING: no network after ~120s >> "%LOG%")
21
+
22
+ rem Copy off the read-only ISO so it survives the ISO being detached after install.
23
+ copy /y "%~dp0bootstrap-windows-worker.ps1" C:\bootstrap-windows-worker.ps1 >> "%LOG%" 2>&1
24
+
25
+ rem The operator's PUBLIC SSH key, if one was staged beside this file. This is what turns
26
+ rem "one console visit per box" into "none": bootstrap installs it into
27
+ rem administrators_authorized_keys with the ACL sshd insists on, and the machine is then
28
+ rem reachable by Ansible from the moment it finishes.
29
+ rem
30
+ rem Staged at ISO-build time rather than committed. A public key is not a secret, but it IS
31
+ rem specific to whoever runs this fleet, and a checked-in one would silently grant access to
32
+ rem whoever happened to be in the repo -- which is a worse default than asking.
33
+ rem
34
+ rem Copied to a FILE rather than exported as an environment variable. The fallback path below runs
35
+ rem the bootstrap through a scheduled task, which starts a fresh session and inherits nothing from
36
+ rem here -- so an exported variable would work on the elevated path and silently vanish on the other,
37
+ rem which is the kind of "works when I tested it" difference this project keeps paying for.
38
+ if exist "%~dp0operator-key.pub" (
39
+ if not exist "C:\ProgramData\a11y-witness" mkdir "C:\ProgramData\a11y-witness" >> "%LOG%" 2>&1
40
+ copy /y "%~dp0operator-key.pub" "C:\ProgramData\a11y-witness\operator-key.pub" >> "%LOG%" 2>&1
41
+ echo [%DATE% %TIME%] operator key staged from the install media >> "%LOG%"
42
+ ) else (
43
+ echo [%DATE% %TIME%] no operator-key.pub beside this script; this box will need one console >> "%LOG%"
44
+ echo [%DATE% %TIME%] visit, or "ansible-playbook ssh-key.yml" once it is reachable >> "%LOG%"
45
+ )
46
+
47
+ rem FirstLogonCommands usually run elevated, but that is not guaranteed. Try inline
48
+ rem first; fall back to a RunLevel Highest scheduled task, which elevates silently.
49
+ powershell -NoProfile -ExecutionPolicy Bypass -Command ^
50
+ "$e=(New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator);" ^
51
+ "if ($e) { Write-Output 'already elevated; running inline'; & powershell -NoProfile -ExecutionPolicy Bypass -File C:\bootstrap-windows-worker.ps1 }" ^
52
+ "else { Write-Output 'not elevated; via RunLevel Highest task';" ^
53
+ " $a=New-ScheduledTaskAction -Execute 'powershell' -Argument '-NoProfile -ExecutionPolicy Bypass -File C:\bootstrap-windows-worker.ps1';" ^
54
+ " $p=New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest;" ^
55
+ " Register-ScheduledTask -TaskName 'a11y-firstboot' -Action $a -Principal $p -Force | Out-Null;" ^
56
+ " Start-ScheduledTask -TaskName 'a11y-firstboot' }" >> "%LOG%" 2>&1
57
+
58
+ echo [%DATE% %TIME%] first-boot finished >> "%LOG%"