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