@trillboards/edge-sdk 0.2.19 → 0.2.21

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.
@@ -93,3 +93,44 @@ stable download URL never changes while field tests remain artifact-verifiable.
93
93
  - The legacy `install-service.ps1` (local-inference: requires system Node + npm
94
94
  install + ONNX models + a hand-edited deviceToken) is a different, older path.
95
95
  This local-first installer supersedes it for BYO-CCTV onboarding.
96
+
97
+ ## Auto-update (since 0.2.20)
98
+
99
+ An installed connector keeps itself current. It is the only consumer of
100
+ `trillboards-connector-windows.version.json`, which until now was published,
101
+ correct, and read by nothing.
102
+
103
+ - **Check** — `src/connectorUpdate.ts` reads the manifest every 6 h (first check
104
+ 60 s after start, so a PC that reboots nightly still reports) and posts the
105
+ running version plus the advertised one to
106
+ `POST /v2/earner/cameras/connector-status`. A MISSING manifest answers
107
+ `HTTP 200 text/html` on this CDN, so the client validates every field rather
108
+ than the status code, and an unreadable manifest reports UNKNOWN — never
109
+ "up to date".
110
+ - **Payload** — a code-only `bin\` + `lib\` zip, a small fraction of the 2.6 GB
111
+ customer installer, because the ~2.4 GB Gemma weights already have their own
112
+ hourly SHA-256-verified OTA channel. The release step prints the measured size;
113
+ the macOS equivalent measured 207 MB of a 2.27 GB pkg. Published under a
114
+ content-addressed prefix so a cached payload can never disagree with its
115
+ catalog.
116
+ - **Verify** — `apply-update.ps1 -Stage` checks the Authenticode signature AND
117
+ the signer CN of a signed catalog, binds it to the manifest's version, then
118
+ checks the payload's size and SHA-256 against it, then that every executable
119
+ in the expanded tree is signed. The signed `.ps1` catalog exists because a
120
+ `.zip` and a `.json` cannot carry Authenticode, and a hash arriving over the
121
+ channel it protects is a corruption check, not an authenticity check.
122
+ - **Apply** — `apply-update.ps1 -Commit` hands the swap to `install-update.ps1`
123
+ unchanged. The connector picks the moment: it waits until it holds no
124
+ in-flight analysis and no flagged frames, because `stop()` discards held
125
+ candidates. After 2 h without a quiet moment it applies anyway and records
126
+ which branch it took.
127
+ - **Task shape** — re-registration produces the best shape the CALLER can make,
128
+ and a headless S4U/boot shape needs an elevated caller. The applier probes
129
+ first and REFUSES the update rather than silently returning a headless machine
130
+ to logon-only; it also re-classifies and restores after the swap.
131
+
132
+ Outcomes land in `<install>\.auto-update\outcome.json` and are reported on the
133
+ next start, so a refused update is visible instead of a machine that quietly
134
+ stopped receiving fixes. Proved on `windows-latest` by
135
+ `ci/verify-update-applier.ps1`, where every negative arm carries a control that
136
+ must fire. Full detail: `docs/runbooks/cctv-connector-windows-gtm-readiness.md` §5.
@@ -0,0 +1,530 @@
1
+ <#
2
+ .SYNOPSIS
3
+ apply-update.ps1 - download, VERIFY, and apply a connector update in the field.
4
+
5
+ .DESCRIPTION
6
+ The half the product was missing. Every release publishes a signed installer
7
+ and a version manifest to the CDN, and nothing read either: a field fix
8
+ reached a customer only when that customer re-downloaded 2.6 GB and re-ran the
9
+ installer by hand. The connector now reads the manifest (see
10
+ src/connectorUpdate.ts) and drives THIS script when it finds itself behind.
11
+
12
+ Two phases, because a store's uplink is slow and sensing must not stop while
13
+ bytes are moving:
14
+
15
+ -Stage download + verify + extract, with the connector still running and
16
+ still sensing. Touches nothing in the live install. Ends by
17
+ writing staged.json, which is the connector's signal that a
18
+ verified tree is on disk and waiting.
19
+ -Commit swap it in. Called only after the connector has decided it is at a
20
+ safe point, and only ever by a connector that is about to exit.
21
+
22
+ ==========================================================================
23
+ WHAT IS VERIFIED BEFORE ANY BYTE IS APPLIED, AND WHY IN THIS ORDER
24
+ ==========================================================================
25
+ An updater that installs an unverified payload is a remote-code-execution
26
+ channel into every customer's store. So the payload is not trusted because it
27
+ came from our CDN - it is trusted because Microsoft's root CA says we signed
28
+ it.
29
+
30
+ 1. AUTHENTICODE on the catalog. `Get-AuthenticodeSignature` must return
31
+ Valid AND the signer subject must be the expected publisher. Status alone
32
+ is not enough: a certificate minted by anyone and installed in the
33
+ machine's trusted roots also returns Valid, so the SUBJECT is what pins
34
+ this to us. Both halves have a control in CI that must fail.
35
+ 2. VERSION BINDING. The catalog names the version it describes and it must
36
+ equal the version the manifest advertised. Without this, a catalog+payload
37
+ pair from an older release could be replayed against a newer manifest.
38
+ 3. SIZE, then SHA-256, of the payload archive against the catalog. The catalog
39
+ is signed and the archive is not, so this is the step that extends
40
+ Authenticode's guarantee over every byte of the payload - including
41
+ lib\edge-sdk\dist\cli.js, which is the code that matters most and which
42
+ cannot carry a signature of its own.
43
+
44
+ Why a signed CATALOG rather than the manifest's own sha256: the manifest is
45
+ plain JSON on the same channel as the payload, so an attacker who can serve
46
+ one can serve the other. A hash that arrives over the channel it is meant to
47
+ protect is a CORRUPTION check, not an authenticity check. A .ps1 can carry
48
+ Authenticode; a .json and a .zip cannot.
49
+
50
+ 4. INNER SIGNATURES. Every .ps1/.vbs and ffmpeg.exe in the extracted tree, and
51
+ node.exe's vendor signature, must be Valid. The hash above already proves
52
+ the archive is the one we built, so this cannot catch an attacker - it
53
+ catches US, if a build ever regresses and ships an unsigned script that
54
+ would then fail to run on a customer under GPO AllSigned.
55
+
56
+ ==========================================================================
57
+ WHAT IS DELIBERATELY NOT REINVENTED
58
+ ==========================================================================
59
+ The swap itself is install-update.ps1, unchanged. It already solves the hard
60
+ parts, all of them incident-derived: stop-existing.ps1 DISABLES the task
61
+ before stopping it (a task registered -RestartCount 99 otherwise relaunches
62
+ node.exe straight into the middle of the swap), Assert-BinariesUnlocked proves
63
+ node.exe and ffmpeg.exe are WRITABLE rather than merely process-free (an AV
64
+ scanner holding a handle is caught before the installer commits), the task is
65
+ re-enabled on EVERY exit path, and bin/lib roll back from .previous on
66
+ failure. This script calls it and adds nothing to it.
67
+
68
+ ==========================================================================
69
+ THE ONE THING THIS SCRIPT ADDS TO THE SWAP: A TASK-SHAPE GUARD
70
+ ==========================================================================
71
+ install-update.ps1 re-registers the scheduled task by running the NEW payload's
72
+ register-task.ps1 - which is correct, because the new version's registration
73
+ logic is the one that should win. But register-task.ps1 registers the best
74
+ shape THE CALLER CAN CREATE, and a headless (S4U / boot-triggered) shape can
75
+ only be created by a caller that is BOTH elevated AND the account itself.
76
+ This script runs from the connector's own task, which is RunLevel Limited. So
77
+ a machine whose owner accepted one UAC prompt to become headless would be
78
+ silently re-registered logon-only by an unattended update - and the loss would
79
+ not be visible until the next unattended reboot, days later, with the machine
80
+ up and no error anywhere.
81
+
82
+ So, before the swap: if the CURRENT task is headless, register a throwaway
83
+ probe task carrying the same principal (and boot trigger, when present). If
84
+ the probe is refused, re-registration would downgrade this machine, and the
85
+ update is REJECTED rather than applied. Nothing is touched; the connector
86
+ stays on the older version and the server is told exactly why. After the swap
87
+ the shape is re-classified anyway and restored from the captured definition if
88
+ it moved - belt as well as braces, because the probe predicts and the
89
+ re-classification measures.
90
+
91
+ The probe task is registered DISABLED-by-construction (no trigger it can fire
92
+ during the probe, an inert action) and removed in a finally.
93
+
94
+ .OUTPUTS
95
+ Exit 0 on success. Non-zero on any failure. Every terminal state also writes
96
+ <InstallDir>\.auto-update\outcome.json, which the connector reads on its next
97
+ start and reports to the server - so a failed update is visible in the fleet
98
+ view rather than being a machine that quietly stopped receiving fixes.
99
+ #>
100
+ [CmdletBinding()]
101
+ param(
102
+ [Parameter(Mandatory = $true)][string]$InstallDir,
103
+ [switch]$Stage,
104
+ [switch]$Commit,
105
+ # Stage-phase inputs, all read from the published manifest by the connector.
106
+ [string]$UpdateUrl,
107
+ [string]$CatalogUrl,
108
+ [string]$ExpectedVersion,
109
+ [string]$CurrentVersion,
110
+ # The publisher every shipped Trillboards artifact carries. Overridable ONLY so
111
+ # CI can prove the check with a throwaway certificate; a static test asserts
112
+ # this default, so a CI harness can never prove the wrong publisher.
113
+ [string]$ExpectedSigner = 'Aethercorp Inc',
114
+ # Escape hatch for the CI control that must FAIL: with this set, a degraded
115
+ # task shape is measured and reported but not repaired. Never set in the field.
116
+ [switch]$SkipTaskShapeRestore
117
+ )
118
+
119
+ $ErrorActionPreference = 'Stop'
120
+ # Invoke-WebRequest renders a progress bar per chunk in Windows PowerShell 5.1,
121
+ # which dominates the wall time of a 200 MB download on a slow uplink.
122
+ $ProgressPreference = 'SilentlyContinue'
123
+
124
+ $TaskName = 'TrillboardsEdge'
125
+ $ProbeTaskName = 'TrillboardsEdgeUpdateProbe'
126
+ $UpdateDir = Join-Path $InstallDir '.auto-update'
127
+ $PayloadDir = Join-Path $UpdateDir 'payload'
128
+ $ArchivePath = Join-Path $UpdateDir 'payload.zip'
129
+ $CatalogPath = Join-Path $UpdateDir 'catalog.ps1'
130
+ $StagedMarker = Join-Path $UpdateDir 'staged.json'
131
+ $OutcomePath = Join-Path $UpdateDir 'outcome.json'
132
+ $AttemptedAt = (Get-Date).ToUniversalTime().ToString('o')
133
+
134
+ function Log([string]$Message) { Write-Host "[apply-update] $Message" }
135
+
136
+ <#
137
+ Write the terminal state, atomically, then leave. The connector reads this on
138
+ its next start; a torn file would be read as a malformed outcome and dropped,
139
+ so tmp+rename rather than a direct write.
140
+
141
+ Vocabulary, and it is load-bearing because the server persists it:
142
+ rejected - we refused. NOTHING was changed. Hash, signature, disk, or a
143
+ swap that would have downgraded the task shape.
144
+ failed - we tried and the transaction did not complete. install-update.ps1
145
+ rolled back, or the shape moved and could not be restored.
146
+ success - the new version is live and the task shape is intact.
147
+ #>
148
+ function Write-Outcome([string]$Result, [string]$Detail) {
149
+ try {
150
+ if (-not (Test-Path -LiteralPath $UpdateDir)) {
151
+ New-Item -ItemType Directory -Force -Path $UpdateDir | Out-Null
152
+ }
153
+ $outcome = [ordered]@{
154
+ attemptedAt = $AttemptedAt
155
+ fromVersion = $CurrentVersion
156
+ toVersion = $ExpectedVersion
157
+ result = $Result
158
+ detail = $Detail
159
+ }
160
+ $json = $outcome | ConvertTo-Json -Compress
161
+ $tmp = "$OutcomePath.tmp"
162
+ [System.IO.File]::WriteAllText($tmp, $json, (New-Object System.Text.UTF8Encoding($false)))
163
+ Move-Item -LiteralPath $tmp -Destination $OutcomePath -Force
164
+ } catch {
165
+ Log "could not record the outcome: $($_.Exception.Message)"
166
+ }
167
+ }
168
+
169
+ function Fail([string]$Result, [string]$Detail) {
170
+ Log "$Result - $Detail"
171
+ Write-Outcome $Result $Detail
172
+ exit 1
173
+ }
174
+
175
+ <#
176
+ Free space on the volume the install lives on.
177
+
178
+ Get-PSDrive rather than wmic: `wmic` is REMOVED on recent Windows 11, and the
179
+ connector's own diagnose path already had to grow a Get-PSDrive fallback for
180
+ exactly that. Returns $null when it cannot be measured, and the caller treats
181
+ "cannot measure" as "do not proceed" - a preflight that passes because it
182
+ could not look is the disk check that reported `pass` on a full disk.
183
+ #>
184
+ function Get-FreeBytes([string]$Path) {
185
+ try {
186
+ $qualifier = Split-Path -Qualifier ([System.IO.Path]::GetFullPath($Path))
187
+ $drive = Get-PSDrive -Name $qualifier.TrimEnd(':') -ErrorAction Stop
188
+ if ($null -eq $drive.Free) { return $null }
189
+ return [int64]$drive.Free
190
+ } catch {
191
+ return $null
192
+ }
193
+ }
194
+
195
+ function Assert-SignedBy([string]$Path, [string]$Subject, [string]$What) {
196
+ $sig = Get-AuthenticodeSignature -LiteralPath $Path
197
+ if ($sig.Status -ne 'Valid') {
198
+ Fail 'rejected' "$What signature is not Valid (status $($sig.Status)): $($sig.StatusMessage)"
199
+ }
200
+ $signer = ''
201
+ if ($sig.SignerCertificate) { $signer = [string]$sig.SignerCertificate.Subject }
202
+ # Match the CN specifically. A substring match anywhere in the subject would
203
+ # accept a certificate that merely mentions the name in its O or OU.
204
+ if ($signer -notmatch ("(^|,)\s*CN=" + [regex]::Escape($Subject) + "\s*(,|$)")) {
205
+ Fail 'rejected' "$What is signed by an unexpected publisher: '$signer'"
206
+ }
207
+ Log "$What signature OK (Valid, CN=$Subject)"
208
+ }
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # STAGE
212
+ # ---------------------------------------------------------------------------
213
+ function Invoke-Stage {
214
+ if ([string]::IsNullOrWhiteSpace($UpdateUrl)) { Fail 'rejected' '-UpdateUrl is required to stage' }
215
+ if ([string]::IsNullOrWhiteSpace($CatalogUrl)) { Fail 'rejected' '-CatalogUrl is required to stage' }
216
+ if ([string]::IsNullOrWhiteSpace($ExpectedVersion)) { Fail 'rejected' '-ExpectedVersion is required to stage' }
217
+
218
+ # Clear the previous attempt's working files but KEEP outcome.json. The
219
+ # connector reports that record on its next start, and a report can fail
220
+ # (offline, rotated token); wiping it here would erase the only evidence that
221
+ # an update was ever refused on this machine.
222
+ New-Item -ItemType Directory -Force -Path $UpdateDir | Out-Null
223
+ foreach ($stale in @($PayloadDir, $ArchivePath, $CatalogPath, $StagedMarker)) {
224
+ if (Test-Path -LiteralPath $stale) { Remove-Item -LiteralPath $stale -Recurse -Force }
225
+ }
226
+
227
+ Log "fetching the update catalog: $CatalogUrl"
228
+ try {
229
+ Invoke-WebRequest -Uri $CatalogUrl -OutFile $CatalogPath -UseBasicParsing -TimeoutSec 60
230
+ } catch {
231
+ Fail 'rejected' "could not fetch the update catalog: $($_.Exception.Message)"
232
+ }
233
+
234
+ Assert-SignedBy $CatalogPath $ExpectedSigner 'update catalog'
235
+
236
+ # PARSED, never executed. The signature covers these bytes, so reading them is
237
+ # safe - but running a downloaded script to learn a hash would give the
238
+ # catalog far more authority than it needs, and there is nothing in it that
239
+ # requires an interpreter.
240
+ $catalogText = [System.IO.File]::ReadAllText($CatalogPath)
241
+ $mVersion = [regex]::Match($catalogText, "(?m)^\s*\`$TrillboardsUpdateVersion\s*=\s*'([^']+)'")
242
+ $mSha = [regex]::Match($catalogText, "(?m)^\s*\`$TrillboardsUpdateSha256\s*=\s*'([0-9a-f]{64})'")
243
+ $mSize = [regex]::Match($catalogText, "(?m)^\s*\`$TrillboardsUpdateSizeBytes\s*=\s*(\d+)")
244
+ if (-not ($mVersion.Success -and $mSha.Success -and $mSize.Success)) {
245
+ Fail 'rejected' 'the update catalog is signed but does not declare version, sha256 and sizeBytes'
246
+ }
247
+ $catalogVersion = $mVersion.Groups[1].Value
248
+ $catalogSha = $mSha.Groups[1].Value
249
+ $catalogSize = [int64]$mSize.Groups[1].Value
250
+
251
+ if ($catalogVersion -ne $ExpectedVersion) {
252
+ Fail 'rejected' "catalog describes $catalogVersion but the manifest advertised $ExpectedVersion"
253
+ }
254
+ if ($catalogSize -le 0) { Fail 'rejected' 'the catalog declares a non-positive payload size' }
255
+
256
+ # Preflight against REAL free space, before a single payload byte is written.
257
+ # x3: the archive, its expansion, and headroom for the .previous copy the swap
258
+ # keeps for rollback. Refusing here leaves a machine that is merely stale;
259
+ # discovering it mid-download leaves a machine that is stale AND full.
260
+ $required = $catalogSize * 3
261
+ $free = Get-FreeBytes $InstallDir
262
+ if ($null -eq $free) { Fail 'rejected' 'could not measure free disk space' }
263
+ if ($free -lt $required) {
264
+ Fail 'rejected' ("not enough free disk space: need {0} MB, have {1} MB" -f [math]::Round($required / 1MB), [math]::Round($free / 1MB))
265
+ }
266
+ Log ("disk preflight OK: need {0} MB, have {1} MB" -f [math]::Round($required / 1MB), [math]::Round($free / 1MB))
267
+
268
+ Log "downloading the update payload: $UpdateUrl"
269
+ try {
270
+ Invoke-WebRequest -Uri $UpdateUrl -OutFile $ArchivePath -UseBasicParsing -TimeoutSec 3600
271
+ } catch {
272
+ # A disk that fills DURING the download lands here, and the partial file
273
+ # must not survive: a resumed or retried run would otherwise hash a
274
+ # truncated archive against a full-length expectation forever.
275
+ Remove-Item -LiteralPath $ArchivePath -Force -ErrorAction SilentlyContinue
276
+ Fail 'rejected' "could not download the update payload: $($_.Exception.Message)"
277
+ }
278
+
279
+ $actualSize = (Get-Item -LiteralPath $ArchivePath).Length
280
+ if ($actualSize -ne $catalogSize) {
281
+ Remove-Item -LiteralPath $ArchivePath -Force -ErrorAction SilentlyContinue
282
+ Fail 'rejected' "payload size mismatch: expected $catalogSize bytes, got $actualSize"
283
+ }
284
+ $actualSha = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
285
+ if ($actualSha -ne $catalogSha) {
286
+ Remove-Item -LiteralPath $ArchivePath -Force -ErrorAction SilentlyContinue
287
+ Fail 'rejected' "payload SHA-256 mismatch: expected $catalogSha, got $actualSha"
288
+ }
289
+ Log "payload verified: $actualSize bytes, sha256 $actualSha"
290
+
291
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
292
+ try {
293
+ [System.IO.Compression.ZipFile]::ExtractToDirectory($ArchivePath, $PayloadDir)
294
+ } catch {
295
+ Fail 'rejected' "could not expand the update payload: $($_.Exception.Message)"
296
+ }
297
+ Remove-Item -LiteralPath $ArchivePath -Force -ErrorAction SilentlyContinue
298
+
299
+ foreach ($rel in @('bin\node.exe', 'bin\install-update.ps1', 'bin\stop-existing.ps1', 'bin\register-task.ps1', 'lib\edge-sdk\dist\cli.js')) {
300
+ if (-not (Test-Path -LiteralPath (Join-Path $PayloadDir $rel))) {
301
+ Fail 'rejected' "the expanded payload is missing $rel"
302
+ }
303
+ }
304
+
305
+ Assert-PayloadSignatures $PayloadDir
306
+
307
+ # LAST, and atomically. staged.json is the connector's only signal that a
308
+ # verified tree is waiting, so it must be impossible for a half-staged tree to
309
+ # look ready.
310
+ $staged = [ordered]@{
311
+ version = $ExpectedVersion
312
+ sha256 = $catalogSha
313
+ stagedAt = (Get-Date).ToUniversalTime().ToString('o')
314
+ } | ConvertTo-Json -Compress
315
+ $tmp = "$StagedMarker.tmp"
316
+ [System.IO.File]::WriteAllText($tmp, $staged, (New-Object System.Text.UTF8Encoding($false)))
317
+ Move-Item -LiteralPath $tmp -Destination $StagedMarker -Force
318
+ Log "staged $ExpectedVersion - waiting for the connector to reach a safe point"
319
+ }
320
+
321
+ <#
322
+ Every executable in the staged tree carries a Valid signature.
323
+
324
+ This cannot stop an attacker - the archive hash already did that - and it is
325
+ not here for one. It stops US shipping a tree that a customer under GPO
326
+ AllSigned could not run: an unsigned .ps1 in the payload would install fine
327
+ and then fail at every logon, silently, on exactly the managed estates the
328
+ signing work was done for.
329
+
330
+ node.exe is checked but NOT expected to carry our publisher: it keeps the
331
+ OpenJS Foundation's signature on purpose, because re-signing it would replace
332
+ the vendor's provenance with ours.
333
+ #>
334
+ function Assert-PayloadSignatures([string]$Root) {
335
+ $bad = 0
336
+ $checked = 0
337
+ $files = Get-ChildItem -LiteralPath (Join-Path $Root 'bin') -File -ErrorAction SilentlyContinue |
338
+ Where-Object { $_.Extension -in @('.ps1', '.vbs', '.exe') }
339
+ foreach ($f in $files) {
340
+ $checked++
341
+ $sig = Get-AuthenticodeSignature -LiteralPath $f.FullName
342
+ if ($sig.Status -ne 'Valid') {
343
+ $bad++
344
+ Log " UNSIGNED OR INVALID: $($f.Name) ($($sig.Status))"
345
+ }
346
+ }
347
+ # A derivation that found nothing must fail rather than pass silently - the
348
+ # shipped bin\ carries at least the eight .ps1, three .vbs and two .exe the
349
+ # release signs, so a count this low means the tree is not what we think.
350
+ if ($checked -lt 10) {
351
+ Fail 'rejected' "only $checked executable artifact(s) found in the staged bin\ - the payload is not the shipped shape"
352
+ }
353
+ if ($bad -gt 0) {
354
+ Fail 'rejected' "$bad of $checked executable artifact(s) in the staged payload are unsigned or invalid"
355
+ }
356
+ Log "all $checked executable artifacts in the staged payload are signed"
357
+ }
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # TASK SHAPE
361
+ # ---------------------------------------------------------------------------
362
+ <#
363
+ Classify the registered task the way the product cares about it.
364
+
365
+ "Headless" is TWO independent properties and it needs both: a trigger that
366
+ fires with nobody logged on (a boot trigger or a repetition), AND a principal
367
+ that does not need an interactive session. Either one alone still waits for a
368
+ human to sign in.
369
+ #>
370
+ function Get-TaskShape([string]$Name) {
371
+ $task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
372
+ if (-not $task) { return $null }
373
+ $xml = Export-ScheduledTask -TaskName $Name
374
+ $logonType = 'unknown'
375
+ $m = [regex]::Match($xml, '<LogonType>([^<]+)</LogonType>')
376
+ if ($m.Success) { $logonType = $m.Groups[1].Value }
377
+ $userId = ''
378
+ $mu = [regex]::Match($xml, '<Principal[^>]*>\s*<UserId>([^<]+)</UserId>')
379
+ if (-not $mu.Success) { $mu = [regex]::Match($xml, '<UserId>([^<]+)</UserId>') }
380
+ if ($mu.Success) { $userId = $mu.Groups[1].Value }
381
+ $hasBoot = $xml -match '<BootTrigger>'
382
+ $hasRepetition = $xml -match '<Repetition>'
383
+ return [pscustomobject]@{
384
+ Xml = $xml
385
+ LogonType = $logonType
386
+ UserId = $userId
387
+ HasBootTrigger = [bool]$hasBoot
388
+ LogonFree = [bool]($hasBoot -or $hasRepetition)
389
+ NonInteractive = ($logonType -ne 'InteractiveToken')
390
+ Headless = ([bool]($hasBoot -or $hasRepetition)) -and ($logonType -ne 'InteractiveToken')
391
+ }
392
+ }
393
+
394
+ function Format-TaskShape($Shape) {
395
+ if ($null -eq $Shape) { return 'absent' }
396
+ return ("logonType=$($Shape.LogonType) logonFreeTrigger=$($Shape.LogonFree) headless=$($Shape.Headless)")
397
+ }
398
+
399
+ <#
400
+ Can THIS caller still create the shape this machine already has?
401
+
402
+ Registers a throwaway task with the same principal, and the boot trigger when
403
+ the current shape has one, then deletes it. It cannot fire while it exists: a
404
+ boot trigger needs a boot, and when the current shape has none the probe is
405
+ registered with NO trigger at all. The action is inert regardless.
406
+
407
+ Returns $true when the probe landed, $false when Windows refused it.
408
+ #>
409
+ function Test-CanRegisterShape($Shape) {
410
+ if ($null -eq $Shape) { return $true }
411
+ $triggers = @()
412
+ if ($Shape.HasBootTrigger) { $triggers = @(New-ScheduledTaskTrigger -AtStartup) }
413
+ $userId = $Shape.UserId
414
+ if ([string]::IsNullOrWhiteSpace($userId)) {
415
+ $userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
416
+ }
417
+ # InteractiveToken is what Export writes; the cmdlet spells it 'Interactive'.
418
+ $logonType = $Shape.LogonType
419
+ if ($logonType -eq 'InteractiveToken') { $logonType = 'Interactive' }
420
+ try {
421
+ Unregister-ScheduledTask -TaskName $ProbeTaskName -Confirm:$false -ErrorAction SilentlyContinue
422
+ $action = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR 'System32\cmd.exe') -Argument '/c exit'
423
+ $principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType $logonType -RunLevel Limited
424
+ if ($triggers.Count -gt 0) {
425
+ Register-ScheduledTask -TaskName $ProbeTaskName -Action $action -Trigger $triggers `
426
+ -Principal $principal -Description 'Trillboards update pre-flight probe' -ErrorAction Stop | Out-Null
427
+ } else {
428
+ Register-ScheduledTask -TaskName $ProbeTaskName -Action $action `
429
+ -Principal $principal -Description 'Trillboards update pre-flight probe' -ErrorAction Stop | Out-Null
430
+ }
431
+ return $true
432
+ } catch {
433
+ Log "task-shape probe refused: $($_.Exception.Message)"
434
+ return $false
435
+ } finally {
436
+ Unregister-ScheduledTask -TaskName $ProbeTaskName -Confirm:$false -ErrorAction SilentlyContinue
437
+ }
438
+ }
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # COMMIT
442
+ # ---------------------------------------------------------------------------
443
+ function Invoke-Commit {
444
+ if (-not (Test-Path -LiteralPath $StagedMarker)) {
445
+ Fail 'rejected' 'no staged update is present'
446
+ }
447
+ $staged = $null
448
+ try {
449
+ $staged = Get-Content -LiteralPath $StagedMarker -Raw | ConvertFrom-Json
450
+ } catch {
451
+ Fail 'rejected' 'the staged marker is unreadable'
452
+ }
453
+ if (-not $staged.version) { Fail 'rejected' 'the staged marker names no version' }
454
+ if ($ExpectedVersion -and $staged.version -ne $ExpectedVersion) {
455
+ Fail 'rejected' "staged $($staged.version) but was asked to commit $ExpectedVersion"
456
+ }
457
+ if (-not $ExpectedVersion) { $script:ExpectedVersion = $staged.version }
458
+ if (-not (Test-Path -LiteralPath (Join-Path $PayloadDir 'bin\install-update.ps1'))) {
459
+ Fail 'rejected' 'the staged payload is gone'
460
+ }
461
+
462
+ $before = Get-TaskShape $TaskName
463
+ Log "task shape before: $(Format-TaskShape $before)"
464
+
465
+ # The predictive half. Only a machine that HAS a headless shape has one to
466
+ # lose, so a logon-only install skips the probe entirely and updates freely.
467
+ if ($null -ne $before -and $before.Headless) {
468
+ if (-not (Test-CanRegisterShape $before)) {
469
+ Fail 'rejected' 'applying this update would re-register the scheduled task logon-only, and this machine is currently headless'
470
+ }
471
+ Log 'task-shape probe OK - the headless shape can be re-created by this caller'
472
+ }
473
+
474
+ # Close the relaunch race before it can happen. The connector exits 0 to hand
475
+ # us the machine, and a task carrying a repetition trigger can start a fresh
476
+ # one in the seconds it takes install-update.ps1 to validate the staged tree -
477
+ # which then holds bin\node.exe open and turns a healthy update into a
478
+ # rollback. stop-existing.ps1 disables the task too, but only AFTER that
479
+ # validation, so the window exists. Nothing between here and the call can
480
+ # fail without install-update.ps1 having run, and that script re-enables the
481
+ # task on every one of its exit paths.
482
+ Disable-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null
483
+
484
+ $installUpdate = Join-Path $PayloadDir 'bin\install-update.ps1'
485
+ try {
486
+ # install-update.ps1 owns the whole swap transaction, including disabling the
487
+ # task before stopping it, proving the binaries are writable, the .previous
488
+ # rollback, and re-enabling the task on every exit path. It throws on
489
+ # failure and $ErrorActionPreference = 'Stop' turns that into ours.
490
+ & $installUpdate -InstallDir $InstallDir -StagingDir $PayloadDir
491
+ } catch {
492
+ Fail 'failed' "the swap failed and the previous version was restored: $($_.Exception.Message)"
493
+ }
494
+
495
+ # The measured half. The probe predicts; this observes.
496
+ $after = Get-TaskShape $TaskName
497
+ Log "task shape after: $(Format-TaskShape $after)"
498
+ $degraded = ($null -ne $before -and $before.Headless -and (($null -eq $after) -or (-not $after.Headless)))
499
+ if ($degraded -and -not $SkipTaskShapeRestore) {
500
+ Log 'the swap left a NON-headless task - restoring the captured definition'
501
+ try {
502
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
503
+ Register-ScheduledTask -TaskName $TaskName -Xml $before.Xml -ErrorAction Stop | Out-Null
504
+ } catch {
505
+ Log "restore failed: $($_.Exception.Message)"
506
+ }
507
+ $after = Get-TaskShape $TaskName
508
+ Log "task shape after restore: $(Format-TaskShape $after)"
509
+ $degraded = ($null -eq $after) -or (-not $after.Headless)
510
+ }
511
+
512
+ Remove-Item -LiteralPath $StagedMarker -Force -ErrorAction SilentlyContinue
513
+ Remove-Item -LiteralPath $PayloadDir -Recurse -Force -ErrorAction SilentlyContinue
514
+
515
+ if ($degraded) {
516
+ # The code IS updated, so this is not a rollback case - but the machine is
517
+ # now worse in a way that only shows up at the next unattended reboot, which
518
+ # is precisely the class of failure that must never be silent.
519
+ Fail 'failed' 'the update applied but the scheduled task is no longer headless'
520
+ }
521
+
522
+ Write-Outcome 'success' $null
523
+ Log "updated to $ExpectedVersion"
524
+ }
525
+
526
+ # ---------------------------------------------------------------------------
527
+ if ($Stage -and $Commit) { Fail 'rejected' 'pass exactly one of -Stage or -Commit' }
528
+ if ($Stage) { Invoke-Stage; exit 0 }
529
+ if ($Commit) { Invoke-Commit; exit 0 }
530
+ Fail 'rejected' 'pass -Stage or -Commit'
@@ -204,6 +204,10 @@ Copy-Item -Force "$Here\run-connector.ps1" "$Payload\bin\run-connector.ps1"
204
204
  Copy-Item -Force "$Here\register-task.ps1" "$Payload\bin\register-task.ps1"
205
205
  Copy-Item -Force "$Here\stop-existing.ps1" "$Payload\bin\stop-existing.ps1"
206
206
  Copy-Item -Force "$Here\install-update.ps1" "$Payload\bin\install-update.ps1"
207
+ # The in-field updater. Ships in bin\ so an INSTALLED connector can drive its own
208
+ # replacement; being here is also what puts it in the Authenticode signing set,
209
+ # which list-shipped-scripts.ps1 derives from these Copy-Item calls.
210
+ Copy-Item -Force "$Here\apply-update.ps1" "$Payload\bin\apply-update.ps1"
207
211
 
208
212
  # Construct the real customer window with Windows PowerShell before packaging.
209
213
  # -SmokeTest creates and validates every control but never displays the form or
@@ -249,4 +253,12 @@ Log "Built: $out ($sizeMb MB)"
249
253
  # failed downstream on `Test-Path $null` with a misleading error. GITHUB_OUTPUT
250
254
  # is unambiguous and survives stream pollution.
251
255
  if ($env:GITHUB_OUTPUT) { "exe=$out" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append }
256
+ # The staged tree is ALSO the auto-update payload: bin\ + lib\ is exactly the
257
+ # shape install-update.ps1 takes as its -StagingDir, and it carries no Gemma
258
+ # weights - those have their own hourly, SHA-256-verified OTA channel. The
259
+ # release job zips this and publishes it beside the manifest so a field fix does
260
+ # not cost every store 2.6 GB. The exact ratio is MEASURED and printed by the
261
+ # release step rather than asserted here; the macOS equivalent measured 207 MB
262
+ # of a 2.27 GB pkg (9.1%), which is the order to expect, not a Windows number.
263
+ if ($env:GITHUB_OUTPUT) { "payload=$Payload" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append }
252
264
  Write-Output $out