@trillboards/edge-sdk 0.2.17 → 0.2.18

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.
@@ -0,0 +1,632 @@
1
+ <#
2
+ .SYNOPSIS
3
+ test-headless-principal.ps1 -- proves the Trillboards connector's scheduled
4
+ task runs with NO interactive session, which is what makes it survive an
5
+ unattended reboot.
6
+
7
+ .DESCRIPTION
8
+ The defect this guards: register-task.ps1 used to register `-AtLogOn` +
9
+ `-LogonType Interactive`, so sensing only ran while one specific user was
10
+ interactively logged on. After a Windows Update reboot on a store back-office
11
+ PC the connector stayed down until somebody signed in -- days, silently, with
12
+ the machine up and no error surfaced anywhere.
13
+
14
+ EVERY assertion here carries a CONTROL that must fail. That is not ceremony:
15
+ a sibling probe for the "no visible window" property once reported 0 windows
16
+ for the fix AND for a deliberately broken control, because a hosted runner has
17
+ no desktop for EnumWindows to walk. It passed on hardware incapable of
18
+ observing the property. So each arm below states what makes it VACUOUS, and
19
+ fails loudly instead of passing when the control does not fire.
20
+
21
+ WHICH ARM COVERS THE PRODUCT'S OWN PATH. Arm B, and only Arm B: it runs the
22
+ real register-task.ps1 as the account that owns the install, unelevated, which
23
+ is exactly what installer.nsi (RequestExecutionLevel user) does. Arm C is a
24
+ SYNTHETIC differential over the principal, so its registration context is not
25
+ part of what it measures -- whether a task can run without a session is decided
26
+ by its principal, not by who wrote it.
27
+
28
+ WHO CAN CREATE AN S4U TASK -- the full rule, measured over three runs, because
29
+ two intermediate readings of it were wrong:
30
+
31
+ as the account ITSELF, ELEVATED works (Arm A, run 31823057985)
32
+ as the account itself, UNELEVATED refused (Arm B, run 31836760844)
33
+ by an elevated admin FOR ANOTHER account refused (run 31838558271), and
34
+ refused even when that account is
35
+ itself an Administrators member
36
+
37
+ So it is BOTH: the caller must be elevated AND must be the account itself. The
38
+ cross-user direction needs SeTcbPrivilege, which an elevated admin does not
39
+ hold, and `Start-Process -Credential` cannot help because it yields a FILTERED
40
+ token. That is why Arm C reaches its subject through a Password-logon task
41
+ with RunLevel Highest -- the only vehicle that produces an elevated,
42
+ session-less process running AS the subject, which can then register S4U for
43
+ itself the same way the product does.
44
+
45
+ Arm A -- SHAPE (uses the real register-task.ps1).
46
+ Asserts the registered task XML carries a LOGON-FREE trigger (<BootTrigger>
47
+ or <Repetition>) and a non-interactive <LogonType>. Control: the pre-fix
48
+ registration (AtLogOn + InteractiveToken) is built inline and the SAME
49
+ predicate must REJECT it.
50
+
51
+ Arm B -- THE UNELEVATED FLOOR (real register-task.ps1, standard NON-admin).
52
+ Asserts what a standard user actually gets. Headless costs ONE elevation on
53
+ Windows: S4U performs a batch logon and Task Scheduler grants
54
+ SeBatchLogonRight only to an administrator CALLER, so a standard user is
55
+ refused S4U even for itself. This arm pins the floor -- the ladder still
56
+ lands, the repeating trigger survives, and it completes PROMPTLESSLY -- and
57
+ asserts the platform rule itself, so a future Windows that allowed
58
+ unelevated S4U would turn this arm red rather than leave a dead rung.
59
+
60
+ Arm C -- RUNTIME, and this is the load-bearing one.
61
+ A second local account, in Administrators and NEVER logged on, registers the
62
+ same marker action twice FOR ITSELF -- once with the pre-fix Interactive
63
+ principal, once with S4U -- and both are started. The principal is the ONLY
64
+ variable. The subject is an elevated admin because that is the only account
65
+ Windows lets create an S4U task, and it is the state a real customer is in
66
+ after accepting the single UAC prompt.
67
+ The Interactive arm must NOT produce its marker; the S4U arm must.
68
+ VACUOUS if the bootstrap never runs, if it is not elevated, if the
69
+ Interactive control also runs, or if the control could not register at all
70
+ (then "it did not run" would say nothing about logon type).
71
+
72
+ Arm D -- THE TASK CAN FIND ITS OWN STATE.
73
+ The S4U marker records the identity, profile root and session id the task
74
+ actually ran with, plus a real outbound HTTPS attempt. A headless task that
75
+ resolved %USERPROFILE% to systemprofile would boot, find no device token and
76
+ no Gemma model, and fail while looking alive -- worse than the outage. This
77
+ arm is why the principal is S4U and not SYSTEM.
78
+
79
+ Arm F -- NO PROMPT A USER CANNOT MEET. ensure-sensing.ps1 owns the single UAC
80
+ prompt, and must not show it to a standard user: UAC would demand credentials
81
+ they cannot supply and -Wait would hang the wizard-close path.
82
+
83
+ Arm E -- MIGRATION. Existing installs carry a logon-only task and there is no
84
+ auto-updater, so ensure-sensing.ps1 repairs the shape in place. Starts from a
85
+ genuinely pre-fix registration and asserts it becomes headless, then asserts
86
+ the repair is idempotent (a second run must not re-register).
87
+
88
+ WHAT THIS CANNOT PROVE. The UAC prompt itself. A hosted runner has no one to
89
+ click it, so the admin-capable-but-unelevated path -- the population for whom
90
+ one click turns 'Interactive + repeat' into 'S4U + boot + repeat' -- is
91
+ exercised only at its two ENDS: Arm A proves an already-elevated caller gets
92
+ the headless shape, and Arm F proves a standard user is never asked. The
93
+ middle, `Start-Process -Verb RunAs` actually being accepted, is unobserved.
94
+
95
+ A hosted runner cannot reboot mid-job either, so Task
96
+ Scheduler actually firing <BootTrigger> or the <Repetition> at boot is
97
+ asserted STRUCTURALLY (the element is in the registered definition), never
98
+ observed. Nor is the repetition INTERVAL waited out -- a 5-minute wait per run
99
+ would buy nothing the structural assertion does not already give. What IS
100
+ observed at runtime is the property that made every trigger useless before:
101
+ the task running to completion with its owning user not logged on.
102
+
103
+ .OUTPUTS exit 0 = passed; exit 1 = failed (including "failed because vacuous").
104
+ #>
105
+ [CmdletBinding()]
106
+ param(
107
+ # Skip the arms that need to create a local account (B/C/D). Off by default;
108
+ # they are the point of this file.
109
+ [switch]$SkipLocalAccountArms
110
+ )
111
+ $ErrorActionPreference = 'Stop'
112
+
113
+ $Here = Split-Path -Parent $MyInvocation.MyCommand.Path # deploy\windows\e2e
114
+ $Deploy = (Resolve-Path (Join-Path $Here '..')).Path # deploy\windows
115
+
116
+ function Log ($m) { Write-Host "[headless-principal] $m" }
117
+ function Pass ($m) { Write-Host "PASS - $m" }
118
+ function Die ($m) { Write-Host "FAIL - $m"; exit 1 }
119
+
120
+ $TempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
121
+ $ProbeRoot = Join-Path $TempRoot 'tb-headless-probe'
122
+ $MarkerDir = Join-Path $ProbeRoot 'markers'
123
+ $RealTask = 'TrillboardsEdge'
124
+ $ControlTask = 'TbHeadlessProbeControl'
125
+ $FixTask = 'TbHeadlessProbeFix'
126
+ $ProbeUser = 'tbheadless' # standard user -> Arm B/F (the unelevated floor)
127
+ $AdminUser = 'tbheadlessadm' # ADMIN, never logged on -> Arm C/D (post-elevation)
128
+ $ProbePass = 'Pr0be!Headless#2026'
129
+
130
+ # ---------------------------------------------------------------- the predicate
131
+ # One definition of "headless", used by BOTH the fix and the control. If this
132
+ # ever gets weakened, the control stops failing and the arm reports vacuous.
133
+ # "Headless" is TWO independent properties and both are required:
134
+ # 1. a trigger that fires with nobody logged on, and
135
+ # 2. a principal that does not need an interactive session.
136
+ # Boot and repetition both satisfy (1). They are not interchangeable in latency
137
+ # -- boot is ~30s, repetition is up to one interval -- but they are
138
+ # interchangeable in the property under test, and only ONE of them is available
139
+ # to a non-admin: Microsoft restricts boot triggers to the Administrators group,
140
+ # and installer.nsi:33 is RequestExecutionLevel user.
141
+ function Test-HeadlessDefinition([string]$Xml) {
142
+ $hasBootTrigger = $Xml -match '<BootTrigger>'
143
+ $hasRepetition = $Xml -match '<Repetition>'
144
+ $logonType = if ($Xml -match '<LogonType>([^<]+)</LogonType>') { $Matches[1] } else { '' }
145
+ # InteractiveToken is the one logon type that REQUIRES a live interactive
146
+ # session. An ABSENT <LogonType> is treated as not-headless on purpose: it is
147
+ # the shape a service-account principal exports, and this product must not run
148
+ # as one (see Arm D -- SYSTEM re-roots every homedir-derived path). Fail closed.
149
+ $nonInteractive = ($logonType -ne '') -and ($logonType -ne 'InteractiveToken')
150
+ [pscustomobject]@{
151
+ Headless = (($hasBootTrigger -or $hasRepetition) -and $nonInteractive)
152
+ BootTrigger = $hasBootTrigger
153
+ Repetition = $hasRepetition
154
+ LogonType = $logonType
155
+ }
156
+ }
157
+
158
+ function Remove-ProbeTask([string]$Name) {
159
+ Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue
160
+ }
161
+
162
+ function Get-TaskShape([string]$Name) {
163
+ Test-HeadlessDefinition (Export-ScheduledTask -TaskName $Name)
164
+ }
165
+
166
+ # Register the PRE-FIX shape (AtLogOn + Interactive) for the CURRENT user. This
167
+ # is the control, and it is also how Arm E manufactures a genuine legacy install.
168
+ function Register-PreFixTask([string]$Name, $Action) {
169
+ $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
170
+ $t = New-ScheduledTaskTrigger -AtLogOn -User $me
171
+ $p = New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited
172
+ Remove-ProbeTask $Name
173
+ Register-ScheduledTask -TaskName $Name -Action $Action -Trigger $t -Principal $p | Out-Null
174
+ }
175
+
176
+ try {
177
+ if (Test-Path $ProbeRoot) { Remove-Item -Recurse -Force $ProbeRoot -ErrorAction SilentlyContinue }
178
+ New-Item -ItemType Directory -Force -Path $MarkerDir | Out-Null
179
+
180
+ # =========================================================== ARM A -- SHAPE
181
+ Log 'Arm A: registered-task shape, via the real register-task.ps1'
182
+
183
+ $installDir = Join-Path $ProbeRoot 'install'
184
+ $bin = Join-Path $installDir 'bin'
185
+ New-Item -ItemType Directory -Force -Path $bin, (Join-Path $installDir 'logs') | Out-Null
186
+ foreach ($f in @('trillboards-edge-task.vbs', 'run-connector.ps1', 'register-task.ps1', 'ensure-sensing.ps1', 'trillboards-edge.cmd', 'trillboards-hidden-run.vbs')) {
187
+ Copy-Item -Force (Join-Path $Deploy $f) (Join-Path $bin $f)
188
+ }
189
+
190
+ Remove-ProbeTask $RealTask
191
+ & (Join-Path $bin 'register-task.ps1') -InstallDir $installDir | Out-Null
192
+ $fixShape = Get-TaskShape $RealTask
193
+ Log (" fix -> BootTrigger=$($fixShape.BootTrigger) Repetition=$($fixShape.Repetition) LogonType=$($fixShape.LogonType)")
194
+
195
+ $noopAction = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR 'System32\wscript.exe') -Argument 'noop'
196
+ Register-PreFixTask $ControlTask $noopAction
197
+ $ctlShape = Get-TaskShape $ControlTask
198
+ Log (" control -> BootTrigger=$($ctlShape.BootTrigger) Repetition=$($ctlShape.Repetition) LogonType=$($ctlShape.LogonType)")
199
+
200
+ if ($ctlShape.Headless) {
201
+ Die "Arm A is VACUOUS: the pre-fix shape (AtLogOn + Interactive) satisfied the headless predicate. The predicate is broken, not the fix."
202
+ }
203
+ if (-not $fixShape.Headless) {
204
+ Die "register-task.ps1 did NOT register a headless task (BootTrigger=$($fixShape.BootTrigger), Repetition=$($fixShape.Repetition), LogonType='$($fixShape.LogonType)')."
205
+ }
206
+ $state = (Get-ScheduledTask -TaskName $RealTask).State
207
+ if ("$state" -eq 'Disabled') { Die "TrillboardsEdge registered Disabled -- it would never fire at boot." }
208
+ Pass "shape: BootTrigger present, LogonType=$($fixShape.LogonType), state=$state; pre-fix control correctly rejected"
209
+ Remove-ProbeTask $ControlTask
210
+
211
+ # ======================================================== ARM E -- MIGRATION
212
+ # Before touching accounts, because it only needs the current user.
213
+ Log 'Arm E: ensure-sensing.ps1 repairs a legacy logon-only registration'
214
+ Set-Content -LiteralPath (Join-Path $installDir 'trillboards.config.json') -Encoding ASCII -Value '{"cameras":[]}'
215
+ Remove-ProbeTask $RealTask
216
+ Register-PreFixTask $RealTask $noopAction
217
+ $beforeShape = Get-TaskShape $RealTask
218
+ Log (" before repair -> BootTrigger=$($beforeShape.BootTrigger) Repetition=$($beforeShape.Repetition) LogonType=$($beforeShape.LogonType)")
219
+ if ($beforeShape.Headless) {
220
+ Die 'Arm E is VACUOUS: the manufactured legacy task was already headless, so the repair had nothing to do.'
221
+ }
222
+
223
+ $global:LASTEXITCODE = 0
224
+ & (Join-Path $bin 'ensure-sensing.ps1') -InstallDir $installDir
225
+ if ($LASTEXITCODE -ne 0) { Die "ensure-sensing.ps1 exited $LASTEXITCODE while repairing a legacy task." }
226
+ $afterShape = Get-TaskShape $RealTask
227
+ Log (" after repair -> BootTrigger=$($afterShape.BootTrigger) Repetition=$($afterShape.Repetition) LogonType=$($afterShape.LogonType)")
228
+ if (-not $afterShape.Headless) {
229
+ Die "ensure-sensing.ps1 did NOT migrate the legacy task (BootTrigger=$($afterShape.BootTrigger), Repetition=$($afterShape.Repetition), LogonType='$($afterShape.LogonType)'). Existing installs would stay logon-only forever -- there is no auto-updater."
230
+ }
231
+
232
+ # Idempotence: a healthy install must not be re-registered on every wizard
233
+ # close. Compare the WHOLE exported definition -- a re-registration restamps
234
+ # <RegistrationInfo><Date>, and starting a task does not change its XML (run
235
+ # times live in ScheduledTaskInfo, not the definition). Deliberately NOT an
236
+ # [xml] cast: Export-ScheduledTask returns a string declaring encoding="UTF-16"
237
+ # with no byte-order mark, and casting that throws "There is no Unicode byte
238
+ # order mark. Cannot switch to Unicode."
239
+ $definitionBefore = Export-ScheduledTask -TaskName $RealTask
240
+ $global:LASTEXITCODE = 0
241
+ & (Join-Path $bin 'ensure-sensing.ps1') -InstallDir $installDir
242
+ if ($LASTEXITCODE -ne 0) { Die "ensure-sensing.ps1 exited $LASTEXITCODE on an already-healthy task." }
243
+ $definitionAfter = Export-ScheduledTask -TaskName $RealTask
244
+ if ($definitionBefore -ne $definitionAfter) {
245
+ $d1 = if ($definitionBefore -match '<Date>([^<]+)</Date>') { $Matches[1] } else { 'unknown' }
246
+ $d2 = if ($definitionAfter -match '<Date>([^<]+)</Date>') { $Matches[1] } else { 'unknown' }
247
+ Die "ensure-sensing.ps1 re-registered an ALREADY-headless task ($d1 -> $d2). That churns a healthy install and interrupts sensing on every wizard close."
248
+ }
249
+ Pass "migration: logon-only -> headless in place, and the repair is idempotent"
250
+ Remove-ProbeTask $RealTask
251
+
252
+ if ($SkipLocalAccountArms) {
253
+ Log 'Arm B/C/D skipped by request.'
254
+ exit 0
255
+ }
256
+
257
+ # ------------------------------------------------ a user who is NOT logged on
258
+ Log "Arm B/C/D: creating standard (non-admin) local user '$ProbeUser'"
259
+ $secure = ConvertTo-SecureString $ProbePass -AsPlainText -Force
260
+ # -Admin puts the account in Administrators, which is how it comes to hold
261
+ # SeBatchLogonRight -- the right S4U needs and the one a standard user cannot
262
+ # be granted without an elevated caller.
263
+ function New-ProbeAccount([string]$Name, [switch]$Admin) {
264
+ Get-LocalUser -Name $Name -ErrorAction SilentlyContinue | Remove-LocalUser -ErrorAction SilentlyContinue
265
+ New-LocalUser -Name $Name -Password $secure -AccountNeverExpires -PasswordNeverExpires `
266
+ -UserMayNotChangePassword -Description 'Trillboards headless-principal probe' | Out-Null
267
+ $groupSid = if ($Admin) { 'S-1-5-32-544' } else { 'S-1-5-32-545' }
268
+ Add-LocalGroupMember -Group (Get-LocalGroup -SID $groupSid).Name -Member $Name -ErrorAction SilentlyContinue
269
+ }
270
+ # Members of Users ONLY. Never Administrators -- Arm B would be meaningless.
271
+ New-ProbeAccount -Name $ProbeUser
272
+
273
+ # Both probe accounts must READ the scripts and WRITE the markers. Everyone
274
+ # (S-1-1-0) rather than Users, because the Arm C/D account is an admin and is
275
+ # not necessarily in Users.
276
+ & icacls.exe $ProbeRoot /grant '*S-1-1-0:(OI)(CI)RX' /T | Out-Null
277
+ & icacls.exe $MarkerDir /grant '*S-1-1-0:(OI)(CI)M' /T | Out-Null
278
+
279
+ $cred = New-Object System.Management.Automation.PSCredential($ProbeUser, $secure)
280
+ $psExe = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe'
281
+
282
+ # Run something AS the probe user and return the exit code. Everything the
283
+ # product does, it does as this user.
284
+ function Invoke-AsProbeUser([string[]]$ScriptArgs, [string]$Tag) {
285
+ $out = Join-Path $ProbeRoot "$Tag-stdout.txt"
286
+ $err = Join-Path $ProbeRoot "$Tag-stderr.txt"
287
+ $p = Start-Process -FilePath $psExe -Credential $cred -LoadUserProfile -WorkingDirectory $bin `
288
+ -ArgumentList (@('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File') + $ScriptArgs) `
289
+ -RedirectStandardOutput $out -RedirectStandardError $err -PassThru -Wait
290
+ foreach ($pair in @(@('stdout', $out), @('stderr', $err))) {
291
+ if (Test-Path $pair[1]) {
292
+ $text = (Get-Content -Raw $pair[1])
293
+ if ($text -and $text.Trim()) { Log " [$Tag $($pair[0])] $($text.Trim())" }
294
+ }
295
+ }
296
+ return $p.ExitCode
297
+ }
298
+
299
+ # Materialise the profile ONCE so %USERPROFILE% under the task is a real
300
+ # profile. On a customer machine this is always true: the installing user has
301
+ # logged on. Without it Arm D would be measuring first-logon profile creation.
302
+ Log ' materialising the probe user profile'
303
+ Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $cred -LoadUserProfile `
304
+ -WorkingDirectory 'C:\' -Wait -ErrorAction Stop
305
+ $probeProfile = (Get-CimInstance Win32_UserProfile -ErrorAction SilentlyContinue |
306
+ Where-Object { $_.LocalPath -like "*\$ProbeUser" } | Select-Object -First 1).LocalPath
307
+ Log " probe user profile: $probeProfile"
308
+
309
+ # Prove the premise of the whole differential: this user has NO session.
310
+ # `query user` exits 1 and writes to stderr when there are no sessions at all,
311
+ # which under $ErrorActionPreference='Stop' becomes a NativeCommandError. That
312
+ # case is the EXPECTED one here, so it must not abort the probe.
313
+ $sessions = ''
314
+ try { $sessions = (& query.exe user 2>&1 | Out-String) }
315
+ catch { $sessions = "query user reported no sessions: $($_.Exception.Message)" }
316
+ Log " query user ->`n$sessions"
317
+ if ($sessions -match [regex]::Escape($ProbeUser)) {
318
+ Die "Arm C premise broken: '$ProbeUser' HAS an interactive session, so the Interactive control could legitimately run."
319
+ }
320
+ Pass "premise: '$ProbeUser' has no interactive session"
321
+
322
+ # ============================================== ARM B -- THE UNELEVATED FLOOR
323
+ # This arm asserts what a standard user actually gets, and it is NOT headless.
324
+ # Measured here, run 31836760844: S4U is refused to a standard user registering
325
+ # for ITSELF ("Access is denied") because S4U performs a BATCH logon and Task
326
+ # Scheduler grants SeBatchLogonRight only when the CALLER is an administrator.
327
+ # Boot triggers are Administrators-only for the same reason. So headless
328
+ # operation costs exactly one elevation on Windows -- that is a platform rule,
329
+ # not something the ladder can code around, and ensure-sensing.ps1 is where the
330
+ # single UAC prompt lives.
331
+ #
332
+ # What MUST hold unelevated: the ladder still lands, the task still exists, it
333
+ # carries the repeating trigger (strictly better than the pre-fix logon-only
334
+ # shape -- it revives a connector that died while somebody was logged on), and
335
+ # the script neither hangs nor throws. A standard user must never be shown a
336
+ # UAC credential prompt they cannot satisfy, so this must also complete
337
+ # PROMPTLESSLY -- a hang here would be an install that never finishes.
338
+ Log 'Arm B: the real register-task.ps1, run by the NON-admin user (unelevated floor)'
339
+ Remove-ProbeTask $RealTask
340
+ $armBStart = [DateTime]::UtcNow
341
+ $armBExit = Invoke-AsProbeUser @(('"' + (Join-Path $bin 'register-task.ps1') + '"'), '-InstallDir', ('"' + $installDir + '"')) 'armB'
342
+ $armBSeconds = ([DateTime]::UtcNow - $armBStart).TotalSeconds
343
+ Log (" exit code: $armBExit elapsed: {0:N1}s" -f $armBSeconds)
344
+ if ($armBExit -ne 0) {
345
+ Die "Arm B: register-task.ps1 exited $armBExit for a standard user. An unelevated install must still produce a working task."
346
+ }
347
+ if (-not (Get-ScheduledTask -TaskName $RealTask -ErrorAction SilentlyContinue)) {
348
+ Die 'Arm B: a standard non-admin user could NOT register the connector task at all. The installer is RequestExecutionLevel user, so this would break every install.'
349
+ }
350
+ $armBShape = Get-TaskShape $RealTask
351
+ Log " non-admin registration -> BootTrigger=$($armBShape.BootTrigger) Repetition=$($armBShape.Repetition) LogonType=$($armBShape.LogonType)"
352
+ if (-not $armBShape.Repetition) {
353
+ Die "Arm B: the unelevated floor lost the repeating trigger (BootTrigger=$($armBShape.BootTrigger), LogonType='$($armBShape.LogonType)'). That is the only start path a standard user can create, and without it this rung is no better than the pre-fix shape."
354
+ }
355
+ # Documenting the platform rule as an ASSERTION, so that if a future Windows
356
+ # build ever does allow unelevated S4U this arm goes red and tells us, instead
357
+ # of the ladder silently carrying a rung nobody needs any more.
358
+ if ($armBShape.Headless) {
359
+ Die "Arm B: an unelevated standard user registered a HEADLESS task. That contradicts the measurement this design is built on (S4U refused unelevated). Re-check whether the elevation prompt in ensure-sensing.ps1 is still needed."
360
+ }
361
+ Pass "unelevated floor: standard user gets 'Interactive + repeat' promptlessly in $([Math]::Round($armBSeconds,1))s; headless correctly requires elevation"
362
+ Remove-ProbeTask $RealTask
363
+
364
+ # ======================================= ARM F -- NO PROMPT A USER CANNOT MEET
365
+ # ensure-sensing.ps1 asks for elevation, and it must NOT ask a standard user:
366
+ # UAC would show an over-the-shoulder CREDENTIAL prompt they cannot satisfy,
367
+ # and with -Wait that is an install that hangs on the wizard-close path.
368
+ # Deliberately runs against the task ARM B just created, which the probe user
369
+ # OWNS and which is non-headless ('Interactive + repeat') -- exactly the state
370
+ # that arms the elevation branch. Manufacturing a pre-fix task as the ELEVATED
371
+ # runner instead would test nothing real: the probe user cannot replace a task
372
+ # it does not own, so register-task.ps1 would fail on an ownership error
373
+ # ("Cannot create a file when that file already exists") that no customer hits,
374
+ # because on a customer machine the task belongs to the installing user.
375
+ Log 'Arm F: ensure-sensing.ps1 as the NON-admin user must not prompt or hang'
376
+ $armFStart = [DateTime]::UtcNow
377
+ $armFExit = Invoke-AsProbeUser @(('"' + (Join-Path $bin 'ensure-sensing.ps1') + '"'), '-InstallDir', ('"' + $installDir + '"')) 'armF'
378
+ $armFSeconds = ([DateTime]::UtcNow - $armFStart).TotalSeconds
379
+ Log (" exit code: $armFExit elapsed: {0:N1}s" -f $armFSeconds)
380
+ if ($armFSeconds -gt 60) {
381
+ Die "Arm F: ensure-sensing.ps1 took $([Math]::Round($armFSeconds,1))s for a standard user -- that is the signature of a UAC prompt waiting for credentials nobody can supply."
382
+ }
383
+ if (-not (Get-ScheduledTask -TaskName $RealTask -ErrorAction SilentlyContinue)) {
384
+ Die 'Arm F: ensure-sensing.ps1 left a standard user with NO task.'
385
+ }
386
+ Pass "no unsatisfiable prompt: ensure-sensing.ps1 completed for a standard user in $([Math]::Round($armFSeconds,1))s"
387
+
388
+ # The elevation branch is gated on [Environment]::UserInteractive, and the ONLY
389
+ # caller that can reach it is trillboards-setup.ps1's Resume-BackgroundSensing,
390
+ # which launches ensure-sensing.ps1 with -NonInteractive, CreateNoWindow and
391
+ # WindowStyle Hidden. If any of those flipped UserInteractive to False the
392
+ # prompt would never fire and the fix would reach nobody -- silently, which is
393
+ # this bug's whole signature. Measured as a DIFFERENTIAL on the -NonInteractive
394
+ # flag so the answer does not depend on the runner's own baseline: whatever the
395
+ # baseline is, the flag must not change it.
396
+ $uiProbe = Join-Path $ProbeRoot 'ui-probe.ps1'
397
+ Set-Content -LiteralPath $uiProbe -Encoding ASCII -Value @'
398
+ param([string]$OutFile)
399
+ Set-Content -LiteralPath $OutFile -Encoding ASCII -Value "userInteractive=$([Environment]::UserInteractive)"
400
+ '@
401
+ function Measure-UserInteractive([bool]$NonInteractive) {
402
+ $out = Join-Path $MarkerDir ("ui-" + $NonInteractive + ".txt")
403
+ if (Test-Path $out) { Remove-Item -Force $out }
404
+ $flag = if ($NonInteractive) { ' -NonInteractive' } else { '' }
405
+ $si = New-Object System.Diagnostics.ProcessStartInfo
406
+ $si.FileName = $psExe
407
+ $si.Arguments = ('-NoProfile' + $flag + ' -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}" -OutFile "{1}"' -f $uiProbe, $out)
408
+ $si.UseShellExecute = $false
409
+ $si.CreateNoWindow = $true
410
+ $si.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
411
+ $p = [System.Diagnostics.Process]::Start($si)
412
+ $p.WaitForExit()
413
+ if (-not (Test-Path $out)) { return 'no-result' }
414
+ ((Get-Content -LiteralPath $out -Raw).Trim() -split '=', 2)[1]
415
+ }
416
+ $uiPlain = Measure-UserInteractive $false
417
+ $uiNonInt = Measure-UserInteractive $true
418
+ Log " UserInteractive: without -NonInteractive = $uiPlain ; with -NonInteractive = $uiNonInt"
419
+ if ($uiPlain -eq 'no-result' -or $uiNonInt -eq 'no-result') {
420
+ Die 'Could not measure [Environment]::UserInteractive under the wizard launch flags.'
421
+ }
422
+ if ($uiPlain -ne $uiNonInt) {
423
+ Die "-NonInteractive CHANGES [Environment]::UserInteractive ($uiPlain -> $uiNonInt). trillboards-setup.ps1 launches ensure-sensing.ps1 with that flag, so the elevation branch would be unreachable from the only path that calls it."
424
+ }
425
+ Pass "the wizard's launch flags do not gate the elevation branch (-NonInteractive leaves UserInteractive=$uiNonInt)"
426
+ Remove-ProbeTask $RealTask
427
+
428
+ # ========================================================= ARM C -- RUNTIME
429
+ $markerScript = Join-Path $ProbeRoot 'probe-marker.ps1'
430
+ Set-Content -LiteralPath $markerScript -Encoding ASCII -Value @'
431
+ param([string]$OutFile)
432
+ $net = 'not-attempted'
433
+ for ($i = 0; $i -lt 3; $i++) {
434
+ try {
435
+ $r = Invoke-WebRequest -Uri 'https://api.trillboards.com/health' -UseBasicParsing -TimeoutSec 20
436
+ $net = "transport-ok:$($r.StatusCode)"; break
437
+ } catch {
438
+ # Any HTTP status proves TCP+TLS completed under this token, which is the
439
+ # thing S4U is suspected of breaking. Only a transport error is a real miss.
440
+ if ($_.Exception.Response) { $net = "transport-ok:$([int]$_.Exception.Response.StatusCode)"; break }
441
+ $net = "transport-fail:$($_.Exception.Message)"
442
+ Start-Sleep -Seconds 3
443
+ }
444
+ }
445
+ $lines = @(
446
+ "identity=$([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)",
447
+ "userProfile=$env:USERPROFILE",
448
+ "localAppData=$env:LOCALAPPDATA",
449
+ "sessionId=$((Get-Process -Id $PID).SessionId)",
450
+ "network=$net"
451
+ )
452
+ Set-Content -LiteralPath $OutFile -Encoding ASCII -Value $lines
453
+ '@
454
+
455
+
456
+ $controlMarker = Join-Path $MarkerDir 'control.txt'
457
+ $fixMarker = Join-Path $MarkerDir 'fix.txt'
458
+
459
+ # Arm C needs an S4U task owned by somebody who is NOT logged on, and Arm B
460
+ # just proved a standard user cannot create one. The subject here is therefore
461
+ # a second local account that IS in Administrators -- which is exactly the
462
+ # population that gets headless in the field, because they are the ones who
463
+ # can accept the UAC prompt. Members of Administrators hold SeBatchLogonRight
464
+ # by default, so no policy has to be touched to reach this state.
465
+ #
466
+ # An EARLIER revision granted SeBatchLogonRight with `secedit /configure
467
+ # /areas USER_RIGHTS` instead. Do not do that: /configure REPLACES the whole
468
+ # user-rights policy with the contents of the .inf, and `secedit /export`
469
+ # emits only the explicitly-configured rights, so re-applying it stripped
470
+ # defaults machine-wide. The visible symptom was the CONTROL arm suddenly
471
+ # failing to register at all ("Access is denied") on a runner where it had
472
+ # worked minutes earlier -- i.e. the probe broke the host it was measuring.
473
+ Log "Arm C/D: creating a local ADMIN user '$AdminUser' who is never logged on"
474
+ New-ProbeAccount -Name $AdminUser -Admin
475
+ $adminCred = New-Object System.Management.Automation.PSCredential($AdminUser, (ConvertTo-SecureString $ProbePass -AsPlainText -Force))
476
+ Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $adminCred -LoadUserProfile `
477
+ -WorkingDirectory 'C:\' -Wait -ErrorAction Stop
478
+ if ((& query.exe user 2>&1 | Out-String) -match [regex]::Escape($AdminUser)) {
479
+ Die "Arm C premise broken: '$AdminUser' HAS an interactive session."
480
+ }
481
+
482
+ # HOW AN S4U TASK CAN BE CREATED AT ALL, measured rather than assumed:
483
+ # as the account ITSELF, ELEVATED -> works (Arm A does exactly this)
484
+ # as the account itself, UNELEVATED -> refused (Arm B, run 31836760844)
485
+ # by an elevated admin FOR ANOTHER account -> refused, even when the target
486
+ # is itself an Administrators
487
+ # member (run 31838558271)
488
+ # The cross-user direction needs SeTcbPrivilege, which an elevated admin does
489
+ # not hold. And `Start-Process -Credential` yields a FILTERED token, so it
490
+ # cannot produce an elevated process as somebody else either.
491
+ #
492
+ # The one remaining vehicle is a task. A Password-logon task runs ELEVATED
493
+ # (RunLevel Highest) and WITHOUT a session, so it can act as $AdminUser and
494
+ # register the two arms FOR ITSELF -- which is the only direction Windows
495
+ # permits, and the same direction the product uses.
496
+ $registrar = Join-Path $ProbeRoot 'probe-register-arms.ps1'
497
+ Set-Content -LiteralPath $registrar -Encoding ASCII -Value @'
498
+ param([string]$MarkerScript, [string]$ControlTask, [string]$ControlMarker,
499
+ [string]$FixTask, [string]$FixMarker, [string]$ResultFile)
500
+ $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
501
+ $elevated = (New-Object System.Security.Principal.WindowsPrincipal(
502
+ [System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
503
+ [System.Security.Principal.WindowsBuiltInRole]::Administrator)
504
+ $psExe = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe'
505
+ function Register-Arm([string]$Name, [string]$LogonType, [string]$Marker) {
506
+ try {
507
+ $a = New-ScheduledTaskAction -Execute $psExe -Argument ('-NoProfile -ExecutionPolicy Bypass -File "' + $MarkerScript + '" -OutFile "' + $Marker + '"')
508
+ $t = New-ScheduledTaskTrigger -AtStartup
509
+ $p = New-ScheduledTaskPrincipal -UserId $me -LogonType $LogonType -RunLevel Limited
510
+ $s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
511
+ Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue
512
+ Register-ScheduledTask -TaskName $Name -Action $a -Trigger $t -Principal $p -Settings $s -ErrorAction Stop | Out-Null
513
+ return 'registered'
514
+ } catch {
515
+ return "REFUSED:$($_.Exception.Message)"
516
+ }
517
+ }
518
+ $lines = @(
519
+ "identity=$me",
520
+ "elevated=$elevated",
521
+ "control=$(Register-Arm $ControlTask 'Interactive' $ControlMarker)",
522
+ "fix=$(Register-Arm $FixTask 'S4U' $FixMarker)"
523
+ )
524
+ Set-Content -LiteralPath $ResultFile -Encoding ASCII -Value $lines
525
+ '@
526
+ & icacls.exe $registrar /grant '*S-1-1-0:RX' | Out-Null
527
+
528
+ $resultFile = Join-Path $MarkerDir 'registrar.txt'
529
+ $bootstrapTask = 'TbHeadlessProbeBootstrap'
530
+ Log "Arm C: bootstrapping an ELEVATED, session-less process as '$AdminUser' to register both arms for itself"
531
+ Remove-ProbeTask $bootstrapTask
532
+ $bootArgs = ('-NoProfile -ExecutionPolicy Bypass -File "{0}" -MarkerScript "{1}" -ControlTask {2} -ControlMarker "{3}" -FixTask {4} -FixMarker "{5}" -ResultFile "{6}"' -f
533
+ $registrar, $markerScript, $ControlTask, $controlMarker, $FixTask, $fixMarker, $resultFile)
534
+ Register-ScheduledTask -TaskName $bootstrapTask `
535
+ -Action (New-ScheduledTaskAction -Execute $psExe -Argument $bootArgs) `
536
+ -Trigger (New-ScheduledTaskTrigger -AtStartup) `
537
+ -User $AdminUser -Password $ProbePass -RunLevel Highest | Out-Null
538
+ Start-ScheduledTask -TaskName $bootstrapTask
539
+ $bootDeadline = [DateTime]::UtcNow.AddSeconds(90)
540
+ while (([DateTime]::UtcNow -lt $bootDeadline) -and -not (Test-Path $resultFile)) { Start-Sleep -Seconds 3 }
541
+ Remove-ProbeTask $bootstrapTask
542
+
543
+ if (-not (Test-Path $resultFile)) {
544
+ Die 'Arm C is VACUOUS: the Password-logon bootstrap task never ran, so no S4U task could be created and nothing below can be concluded.'
545
+ }
546
+ $reg = @{}
547
+ foreach ($line in (Get-Content -LiteralPath $resultFile)) {
548
+ $kv = $line -split '=', 2
549
+ if ($kv.Count -eq 2) { $reg[$kv[0]] = $kv[1] }
550
+ }
551
+ # The bootstrap running at all is itself a result: a non-interactive principal
552
+ # executed with nobody logged on. Arm C then isolates S4U specifically.
553
+ Log " bootstrap ran as: $($reg['identity']) elevated=$($reg['elevated'])"
554
+ Log " control registration: $($reg['control'])"
555
+ Log " fix registration: $($reg['fix'])"
556
+ if ($reg['elevated'] -ne 'True') {
557
+ Die "Arm C is VACUOUS: the bootstrap was not elevated ($($reg['elevated'])), and S4U cannot be registered without elevation, so a refusal below would say nothing about the principal."
558
+ }
559
+
560
+ if ($reg['control'] -notmatch 'registered$') {
561
+ Die "Arm C is VACUOUS: the Interactive control could not be registered at all ($($reg['control'])), so 'the control did not run' would say nothing about logon type."
562
+ }
563
+ if ($reg['fix'] -notmatch 'registered$') {
564
+ Die "Arm C: S4U was refused to an ELEVATED account registering for ITSELF ($($reg['fix'])) -- the one direction Windows is supposed to permit, and the one register-task.ps1 uses. The runtime differential cannot be established on this host, so nothing below it would mean anything. Do NOT weaken this into a pass."
565
+ }
566
+
567
+ foreach ($n in @($ControlTask, $FixTask)) {
568
+ try { Start-ScheduledTask -TaskName $n -ErrorAction Stop; Log " started $n" }
569
+ catch { Log " Start-ScheduledTask $n threw: $($_.Exception.Message)" }
570
+ }
571
+
572
+ # Poll both. The fix should land in seconds; give the control the SAME full
573
+ # budget so "it did not run" cannot be confused with "it was slow".
574
+ $deadline = [DateTime]::UtcNow.AddSeconds(90)
575
+ while ([DateTime]::UtcNow -lt $deadline) {
576
+ if ((Test-Path $fixMarker) -and (Test-Path $controlMarker)) { break }
577
+ Start-Sleep -Seconds 3
578
+ }
579
+ $controlRan = Test-Path $controlMarker
580
+ $fixRan = Test-Path $fixMarker
581
+
582
+ foreach ($n in @($ControlTask, $FixTask)) {
583
+ $info = Get-ScheduledTaskInfo -TaskName $n -ErrorAction SilentlyContinue
584
+ if ($info) { Log (" {0}: LastTaskResult=0x{1:X} LastRunTime={2}" -f $n, $info.LastTaskResult, $info.LastRunTime) }
585
+ }
586
+ Log " control marker present: $controlRan"
587
+ Log " fix marker present: $fixRan"
588
+
589
+ if ($controlRan) {
590
+ Die 'Arm C is VACUOUS: the Interactive-principal task RAN for a user with no interactive session. This host cannot observe the property, so a green fix arm would prove nothing.'
591
+ }
592
+ if (-not $fixRan) {
593
+ Die 'The S4U task did NOT run for a user with no interactive session -- the headless principal does not work here.'
594
+ }
595
+ Pass 'runtime: S4U ran with nobody logged on; the pre-fix Interactive principal did NOT (same action, same user, same trigger)'
596
+
597
+ # ============================================================ ARM D -- STATE
598
+ $marker = @{}
599
+ foreach ($line in (Get-Content -LiteralPath $fixMarker)) {
600
+ $kv = $line -split '=', 2
601
+ if ($kv.Count -eq 2) { $marker[$kv[0]] = $kv[1] }
602
+ }
603
+ Log ' S4U task reported:'
604
+ foreach ($k in $marker.Keys) { Log " $k = $($marker[$k])" }
605
+
606
+ if ($marker['identity'] -notmatch [regex]::Escape($AdminUser)) {
607
+ Die "Arm D: the S4U task ran as '$($marker['identity'])', not as '$AdminUser'."
608
+ }
609
+ if ([string]::IsNullOrWhiteSpace($marker['userProfile']) -or $marker['userProfile'] -match 'systemprofile') {
610
+ Die "Arm D: USERPROFILE under the headless task is '$($marker['userProfile'])'. The connector resolves its device identity, data dir and the ~2.4 GB Gemma model through this root; systemprofile means it would start and then find nothing."
611
+ }
612
+ if ($marker['userProfile'] -notmatch [regex]::Escape($AdminUser)) {
613
+ Die "Arm D: USERPROFILE '$($marker['userProfile'])' is not the owning user's profile."
614
+ }
615
+ if ($marker['network'] -notmatch '^transport-ok') {
616
+ Die "Arm D: outbound HTTPS under the S4U token did not complete: $($marker['network']). S4U grants no NETWORK credentials; this asserts that plain outbound TLS is unaffected."
617
+ }
618
+ Pass "state: USERPROFILE=$($marker['userProfile']) (not systemprofile), sessionId=$($marker['sessionId']), outbound HTTPS $($marker['network'])"
619
+
620
+ Write-Host ''
621
+ Write-Host 'headless-principal E2E PASSED'
622
+ exit 0
623
+ }
624
+ finally {
625
+ foreach ($n in @($RealTask, $ControlTask, $FixTask, 'TbHeadlessProbeBootstrap')) { Remove-ProbeTask $n }
626
+ foreach ($account in @($ProbeUser, $AdminUser)) {
627
+ Get-LocalUser -Name $account -ErrorAction SilentlyContinue | Remove-LocalUser -ErrorAction SilentlyContinue
628
+ Get-CimInstance Win32_UserProfile -ErrorAction SilentlyContinue |
629
+ Where-Object { $_.LocalPath -like "*\$account" } |
630
+ Remove-CimInstance -ErrorAction SilentlyContinue
631
+ }
632
+ }