@trillboards/edge-sdk 0.2.25 → 0.2.36

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.
@@ -51,7 +51,25 @@
51
51
  [CmdletBinding()]
52
52
  param(
53
53
  # Arm K needs to create a local account. Off-switch for a host that forbids it.
54
- [switch]$SkipUnelevatedArm
54
+ [switch]$SkipUnelevatedArm,
55
+ # The production proof owns eight minutes. Seconds are injectable only so the
56
+ # Windows deadline probe can prove a blocked certificate child is killed.
57
+ [ValidateRange(1, 480)]
58
+ [int]$DeadlineSeconds = 480,
59
+ [ValidateRange(0, 60)]
60
+ [int]$CertificateMintDelaySeconds = 0,
61
+ [switch]$CertificateDeadlineProbe,
62
+ # Forces Process.WaitForExit to throw on an unstarted process. CI uses this
63
+ # to prove the wait observer fails closed rather than calling a thrown wait a
64
+ # successful exit and falling into a later unbounded wait.
65
+ [switch]$BoundedWaitFailureProbe,
66
+ # The full CI proof receives the one real certificate already minted, signed,
67
+ # and proven by the preceding AllSigned harness. `powershell.exe` children
68
+ # repeatedly wedged while minting on windows-latest even though the same
69
+ # cmdlet succeeds in the workflow host. Keep the killable child below only for
70
+ # the deliberate deadline mutant; do not use that unreliable process shape
71
+ # as the happy-path fixture factory.
72
+ [string]$CertificateFixturePath
55
73
  )
56
74
 
57
75
  $ErrorActionPreference = 'Stop'
@@ -65,10 +83,95 @@ $Root = Join-Path $env:RUNNER_TEMP 'tb-update-applier'
65
83
  if (-not $env:RUNNER_TEMP) { $Root = Join-Path $env:TEMP 'tb-update-applier' }
66
84
  $Www = Join-Path $Root 'www'
67
85
  $Certs = New-Object System.Collections.Generic.List[string]
86
+ $CertSubjects = New-Object System.Collections.Generic.List[string]
68
87
  $LocalUser = 'tbupd'
69
88
  $Failures = 0
89
+ $StartedAt = Get-Date
70
90
 
71
- function Say([string]$m) { Write-Host "[update-applier] $m" }
91
+ <#
92
+ A SELF-IMPOSED DEADLINE, because the first run of this harness did not fail -
93
+ it ate the whole job. It printed ONE line, ran for 11 minutes, and the JOB hit
94
+ its 15-minute cap, so every other proof in that step lost its budget too and
95
+ the log said only "The operation was canceled" - naming no cause and proving
96
+ nothing. Not one arm had run.
97
+
98
+ A harness that cannot finish must say so ITSELF, at the point it gives up, and
99
+ loudly enough to be actionable. Every phase below also prints its elapsed
100
+ time, because the reason that first failure was undiagnosable was that there
101
+ was no output between "origin listening" and the first arm.
102
+ #>
103
+ $script:DeadlineSeconds = $DeadlineSeconds
104
+
105
+ function Elapsed { ((Get-Date) - $script:StartedAt).TotalSeconds }
106
+ function Say([string]$m) { Write-Host ("[update-applier {0,6:N1}s] {1}" -f (Elapsed), $m) }
107
+
108
+ $script:CurrentPhase = 'startup'
109
+
110
+ function Fail-Deadline([string]$Phase, [string]$When) {
111
+ Write-Host "FAIL - the harness exceeded its $($script:DeadlineSeconds)-second deadline $When`: $Phase"
112
+ Write-Host ' HARNESS BUDGET failure, not a product failure. The arms above ran;'
113
+ Write-Host ' the ones below never started. Read the per-phase timings to find'
114
+ Write-Host ' what got slow before assuming a defect in apply-update.ps1.'
115
+ Cleanup
116
+ exit 1
117
+ }
118
+
119
+ function Assert-WithinDeadline([string]$Phase) {
120
+ $script:CurrentPhase = $Phase
121
+ if ((Elapsed) -lt $script:DeadlineSeconds) { return }
122
+ Fail-Deadline $Phase 'before starting'
123
+ }
124
+
125
+ function Remaining-DeadlineSeconds {
126
+ [Math]::Max(0, $script:DeadlineSeconds - (Elapsed))
127
+ }
128
+
129
+ <#
130
+ Wait for a process, but never past the harness deadline.
131
+
132
+ Assert-WithinDeadline can only sample time BETWEEN phases, so on its own it
133
+ cannot stop a phase that is ALREADY blocked - and that is precisely the
134
+ failure this harness exists to prevent. The first run spent eleven minutes
135
+ inside a single call; the job's 15-minute cap ended it, the harness did not,
136
+ and the log named no cause. A deadline that can only fire between the things
137
+ it is bounding is not a deadline.
138
+
139
+ So every blocking wait goes through here, bounded by the budget REMAINING at
140
+ the moment the wait starts. The deadline therefore stays a whole-harness
141
+ deadline rather than becoming a per-call timeout: eight slow phases cannot
142
+ each consume eight minutes.
143
+
144
+ On expiry we kill the process before reporting, so the cleanup that follows
145
+ is not itself blocked by the thing that hung.
146
+ #>
147
+ function Wait-Bounded {
148
+ param([System.Diagnostics.Process]$Process, [string]$Phase = $script:CurrentPhase)
149
+ $budget = Remaining-DeadlineSeconds
150
+ if ($budget -le 0) {
151
+ try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch {}
152
+ Fail-Deadline $Phase 'before starting'
153
+ }
154
+ $timeoutMilliseconds = [int]([Math]::Min(2147483, $budget) * 1000)
155
+ $exited = $false
156
+ try {
157
+ $exited = $Process.WaitForExit($timeoutMilliseconds)
158
+ } catch {
159
+ $waitException = $_.Exception
160
+ if ($null -ne $waitException.InnerException) { $waitException = $waitException.InnerException }
161
+ $waitErrorType = $waitException.GetType().FullName
162
+ $waitErrorMessage = $waitException.Message
163
+ try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch {}
164
+ try { $Process.WaitForExit(5000) | Out-Null } catch {}
165
+ try { $Process.Close() } catch {}
166
+ Die "$Phase bounded process wait failed ($waitErrorType): $waitErrorMessage"
167
+ }
168
+ if (-not $exited) {
169
+ try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch {}
170
+ try { $Process.WaitForExit(5000) | Out-Null } catch {}
171
+ Fail-Deadline $Phase 'while running'
172
+ }
173
+ return $Process
174
+ }
72
175
  function Pass([string]$m) { Write-Host "PASS - $m" }
73
176
  function Bad([string]$m) { Write-Host "FAIL - $m"; $script:Failures++ }
74
177
  function Die([string]$m) { Write-Host "FAIL - $m"; Cleanup; exit 1 }
@@ -126,17 +229,111 @@ function Stop-Origin {
126
229
  # Certificates
127
230
  # ---------------------------------------------------------------------------
128
231
  function New-HarnessCert([string]$Subject) {
129
- $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=$Subject" `
130
- -CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddDays(2)
232
+ # New-SelfSignedCertificate blocked for longer than the GitHub step budget on
233
+ # 2026-08-15. A synchronous cmdlet cannot be interrupted by Wait-Bounded, so
234
+ # mint in a disposable child process. The child receives the SAME remaining
235
+ # whole-harness budget as every applier invocation and is forcibly killed on
236
+ # expiry; a phase-specific failure is emitted before the job timeout can turn
237
+ # the result into an unattributable cancellation.
238
+ $phase = "minting code-signing certificate '$Subject'"
239
+ $tag = [guid]::NewGuid().ToString('N')
240
+ $thumbprintPath = Join-Path $Root "cert-$tag.thumbprint"
241
+ $stderrPath = Join-Path $Root "cert-$tag.stderr"
242
+ $subjectLiteral = $Subject.Replace("'", "''")
243
+ $thumbprintLiteral = $thumbprintPath.Replace("'", "''")
244
+ $stderrLiteral = $stderrPath.Replace("'", "''")
245
+ $script:CertSubjects.Add("CN=$Subject")
246
+ $mintSource = @"
247
+ `$ErrorActionPreference = 'Stop'
248
+ `$ProgressPreference = 'SilentlyContinue'
249
+ try {
250
+ if ($CertificateMintDelaySeconds -gt 0) { Start-Sleep -Seconds $CertificateMintDelaySeconds }
251
+ `$cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject 'CN=$subjectLiteral' -CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddDays(2)
252
+ [System.IO.File]::WriteAllText('$thumbprintLiteral', `$cert.Thumbprint)
253
+ } catch {
254
+ [System.IO.File]::WriteAllText('$stderrLiteral', (`$_ | Out-String))
255
+ exit 1
256
+ }
257
+ "@
258
+ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($mintSource))
259
+ $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
260
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
261
+ $startInfo.FileName = $powershell
262
+ $startInfo.Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"
263
+ $startInfo.UseShellExecute = $false
264
+ $startInfo.CreateNoWindow = $true
265
+ $p = [Diagnostics.Process]::new()
266
+ $p.StartInfo = $startInfo
267
+ $null = $p.Start()
268
+ $null = Wait-Bounded -Process $p -Phase $phase
269
+ $exitCode = $p.ExitCode
270
+ $p.Close()
271
+
272
+ $childError = ''
273
+ if (Test-Path -LiteralPath $stderrPath) { $childError = (Get-Content -LiteralPath $stderrPath -Raw).Trim() }
274
+ if ($exitCode -ne 0) { Die "$phase failed (exit $exitCode): $childError" }
275
+ if (-not (Test-Path -LiteralPath $thumbprintPath)) { Die "$phase returned no thumbprint" }
276
+ $thumbprint = (Get-Content -LiteralPath $thumbprintPath -Raw).Trim().ToUpperInvariant()
277
+ if ($thumbprint -notmatch '^[0-9A-F]{40,128}$') { Die "$phase returned malformed thumbprint '$thumbprint'" }
278
+ $script:Certs.Add($thumbprint)
279
+ $cert = Get-Item -LiteralPath "Cert:\CurrentUser\My\$thumbprint" -ErrorAction Stop
280
+ if ((-not $cert.HasPrivateKey) -or ($cert.Subject -ne "CN=$Subject")) {
281
+ Die "$phase returned a certificate without the expected subject and private key"
282
+ }
283
+ Remove-Item -LiteralPath $thumbprintPath, $stderrPath -Force -ErrorAction SilentlyContinue
131
284
  # Into Root, or the chain does not build and Status is UnknownError - which
132
285
  # would make arm E fire for the wrong reason entirely.
133
286
  $pub = New-Object Security.Cryptography.X509Certificates.X509Certificate2(, $cert.RawData)
134
287
  $store = New-Object Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
135
288
  $store.Open('ReadWrite'); $store.Add($pub); $store.Close()
136
- $Certs.Add($cert.Thumbprint)
137
289
  return $cert
138
290
  }
139
291
 
292
+ function Read-HarnessCertificateFixtures([string]$Path) {
293
+ if (-not $Path) { Die 'CertificateFixturePath is required for the full harness' }
294
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
295
+ Die "CertificateFixturePath does not exist: $Path"
296
+ }
297
+ try {
298
+ $rows = @(Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json)
299
+ } catch {
300
+ Die "CertificateFixturePath is not valid JSON: $($_.Exception.Message)"
301
+ }
302
+ if ($rows.Count -ne 1) { Die "CertificateFixturePath must contain exactly one certificate (got $($rows.Count))" }
303
+
304
+ $expectedSubjects = @('CN=Trillboards CI AllSigned Harness')
305
+ $bySubject = @{}
306
+ foreach ($row in $rows) {
307
+ $keys = @($row.PSObject.Properties.Name | Sort-Object)
308
+ if (($keys.Count -ne 2) -or (($keys -join ',') -ne 'subject,thumbprint')) {
309
+ Die 'CertificateFixturePath rows must contain exactly subject and thumbprint'
310
+ }
311
+ $subject = "$($row.subject)"
312
+ $thumbprint = "$($row.thumbprint)".Trim().ToUpperInvariant()
313
+ if ($expectedSubjects -notcontains $subject) { Die "CertificateFixturePath contains unexpected subject '$subject'" }
314
+ if ($bySubject.ContainsKey($subject)) { Die "CertificateFixturePath repeats subject '$subject'" }
315
+ if ($thumbprint -notmatch '^[0-9A-F]{40,128}$') { Die "CertificateFixturePath contains malformed thumbprint '$thumbprint'" }
316
+
317
+ $cert = Get-Item -LiteralPath "Cert:\CurrentUser\My\$thumbprint" -ErrorAction SilentlyContinue
318
+ if ((-not $cert) -or (-not $cert.HasPrivateKey) -or ($cert.Subject -ne $subject)) {
319
+ Die "CertificateFixturePath certificate '$subject' is absent or lacks its expected private key"
320
+ }
321
+ $root = Get-Item -LiteralPath "Cert:\LocalMachine\Root\$thumbprint" -ErrorAction SilentlyContinue
322
+ if ((-not $root) -or ($root.Subject -ne $subject) -or ($root.Thumbprint -ne $cert.Thumbprint)) {
323
+ Die "CertificateFixturePath certificate '$subject' is not bound to the matching trusted root"
324
+ }
325
+ $script:Certs.Add($thumbprint)
326
+ $script:CertSubjects.Add($subject)
327
+ $bySubject[$subject] = $cert
328
+ }
329
+ foreach ($subject in $expectedSubjects) {
330
+ if (-not $bySubject.ContainsKey($subject)) { Die "CertificateFixturePath is missing subject '$subject'" }
331
+ }
332
+ return [pscustomobject]@{
333
+ Good = $bySubject['CN=Trillboards CI AllSigned Harness']
334
+ }
335
+ }
336
+
140
337
  function Remove-HarnessCerts {
141
338
  foreach ($tp in $Certs) {
142
339
  foreach ($name in @('My', 'Root')) {
@@ -148,6 +345,18 @@ function Remove-HarnessCerts {
148
345
  } catch {}
149
346
  }
150
347
  }
348
+ # If the child was killed after committing the store write but before writing
349
+ # its thumbprint marker, the parent never saw the thumbprint. Exact harness
350
+ # subjects are therefore a second cleanup key; they are intentionally unique
351
+ # to this ephemeral CI proof.
352
+ foreach ($name in @('My', 'Root')) {
353
+ try {
354
+ $store = New-Object Security.Cryptography.X509Certificates.X509Store($name, 'CurrentUser')
355
+ $store.Open('ReadWrite')
356
+ foreach ($c in @($store.Certificates | Where-Object { $script:CertSubjects.Contains($_.Subject) })) { $store.Remove($c) }
357
+ $store.Close()
358
+ } catch {}
359
+ }
151
360
  }
152
361
 
153
362
  function Sign-File([string]$Path, $Cert) {
@@ -158,27 +367,70 @@ function Sign-File([string]$Path, $Cert) {
158
367
  # ---------------------------------------------------------------------------
159
368
  # Fixtures
160
369
  # ---------------------------------------------------------------------------
370
+ <#
371
+ BUILT ONCE, REUSED. The first version of this harness rebuilt the whole tree
372
+ per arm: 4 trees x (copy the runner's ~110 MB node.exe + Authenticode-sign 12
373
+ scripts + zip the lot). On windows-latest that produced ONE line of output and
374
+ then nothing for 11 minutes, until the job hit its 15-minute cap and was
375
+ cancelled - so not one arm ever ran, and the step looked hung rather than slow
376
+ because there was no progress output between phases at all.
377
+
378
+ Two fixes, and the second is the one that matters for the next person:
379
+
380
+ 1. Sign the shipped script set ONCE into a cache and COPY the signed bytes
381
+ into each tree. Authenticode signing is the expensive part, not the file
382
+ copy, and the signatures are identical across trees by construction.
383
+ 2. bin\node.exe is a small system PE re-signed with the harness certificate,
384
+ NOT the real 110 MB node. The applier only needs it to be PRESENT and
385
+ VALID; the two arms that actually EXECUTE node ask for -RealNode. Re-signing
386
+ also removes the Microsoft CRL distribution point, so the applier's
387
+ Get-AuthenticodeSignature calls resolve against a self-signed chain with
388
+ nothing to look up instead of reaching for the network 13 times per stage.
389
+ #>
390
+ $SignedCache = $null
391
+
392
+ function Initialize-SignedCache($SignCert) {
393
+ if ($script:SignedCache) { return }
394
+ $script:SignedCache = Join-Path $Root 'signed-cache'
395
+ New-Item -ItemType Directory -Force -Path $script:SignedCache | Out-Null
396
+ $t0 = Get-Date
397
+ foreach ($f in @(& (Join-Path $Here 'list-shipped-scripts.ps1') -Kind signable -AbsolutePath)) {
398
+ $dest = Join-Path $script:SignedCache (Split-Path -Leaf $f)
399
+ Copy-Item -LiteralPath $f -Destination $dest -Force
400
+ Sign-File $dest $SignCert
401
+ }
402
+ # A small, already-PE stub that becomes bin\node.exe. where.exe is ~40 KB and
403
+ # is a real signed PE, so re-signing it with the harness cert succeeds and the
404
+ # result carries OUR chain rather than Microsoft's.
405
+ $stub = Join-Path $script:SignedCache 'node.exe'
406
+ Copy-Item -LiteralPath (Join-Path $env:WINDIR 'System32\where.exe') -Destination $stub -Force
407
+ Sign-File $stub $SignCert
408
+ Say ("signed-script cache built in {0:N1}s" -f ((Get-Date) - $t0).TotalSeconds)
409
+ }
410
+
161
411
  <#
162
412
  A tree shaped exactly like the shipped connector: bin\ with the real scripts
163
413
  (so the applier's "at least 10 signed executables" floor is met by the real
164
414
  file set, not a stub), a node.exe, and a cli.js that answers `version` the way
165
415
  install-update.ps1 requires.
166
416
  #>
167
- function New-PayloadTree([string]$Dest, $SignCert, [switch]$LogonOnlyRegisterTask, [switch]$WithUnsignedScript) {
417
+ function New-PayloadTree([string]$Dest, $SignCert, [switch]$LogonOnlyRegisterTask, [switch]$WithUnsignedScript, [switch]$RealNode) {
418
+ Initialize-SignedCache $SignCert
419
+ $t0 = Get-Date
168
420
  if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
169
421
  $bin = Join-Path $Dest 'bin'
170
422
  $dist = Join-Path $Dest 'lib\edge-sdk\dist'
171
423
  New-Item -ItemType Directory -Force -Path $bin, $dist | Out-Null
172
424
 
173
- foreach ($f in @(& (Join-Path $Here 'list-shipped-scripts.ps1') -Kind signable -AbsolutePath)) {
174
- Copy-Item -LiteralPath $f -Destination (Join-Path $bin (Split-Path -Leaf $f)) -Force
175
- }
425
+ Copy-Item -Path (Join-Path $script:SignedCache '*') -Destination $bin -Force
426
+
176
427
  if ($LogonOnlyRegisterTask) {
177
428
  # A register-task.ps1 that deliberately produces the PRE-headless shape.
178
429
  # This is how arm J reproduces, on an elevated runner, the downgrade a
179
430
  # non-elevated field caller would suffer: the cause differs, the observable
180
431
  # (the task comes back logon-only) is identical, and that observable is what
181
- # the guard keys on.
432
+ # the guard keys on. Signed, or the payload would be refused by the
433
+ # signature check before it could ever reach install-update.ps1.
182
434
  $downgrade = @'
183
435
  [CmdletBinding()]
184
436
  param([Parameter(Mandatory = $true)][string]$InstallDir, [string]$Mode = "auto-sense")
@@ -190,29 +442,28 @@ $principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -
190
442
  Unregister-ScheduledTask -TaskName "TrillboardsEdge" -Confirm:$false -ErrorAction SilentlyContinue
191
443
  Register-ScheduledTask -TaskName "TrillboardsEdge" -Action $action -Trigger $trigger -Principal $principal | Out-Null
192
444
  '@
193
- [System.IO.File]::WriteAllText((Join-Path $bin 'register-task.ps1'), $downgrade, (New-Object System.Text.UTF8Encoding($false)))
445
+ $rt = Join-Path $bin 'register-task.ps1'
446
+ [System.IO.File]::WriteAllText($rt, $downgrade, (New-Object System.Text.UTF8Encoding($false)))
447
+ Sign-File $rt $SignCert
194
448
  }
195
449
  if ($WithUnsignedScript) {
196
450
  [System.IO.File]::WriteAllText((Join-Path $bin 'rogue.ps1'), "exit 0`r`n", (New-Object System.Text.UTF8Encoding($false)))
197
451
  }
452
+ if ($RealNode) {
453
+ # Only the arms that actually RUN the connector need a real interpreter.
454
+ Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
455
+ $nodeSig = Get-AuthenticodeSignature -LiteralPath (Join-Path $bin 'node.exe')
456
+ if ($nodeSig.Status -ne 'Valid') { Sign-File (Join-Path $bin 'node.exe') $SignCert }
457
+ }
198
458
 
199
- Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
200
459
  [System.IO.File]::WriteAllText(
201
460
  (Join-Path $dist 'cli.js'),
202
461
  "if (process.argv[2] === 'version') { console.log('@trillboards/edge-sdk v9.9.9'); process.exit(0); }`r`nsetInterval(function(){}, 1000);`r`n",
203
462
  (New-Object System.Text.UTF8Encoding($false)))
204
-
205
- foreach ($f in @(Get-ChildItem -LiteralPath $bin -File | Where-Object { $_.Name -ne 'rogue.ps1' })) {
206
- if ($f.Extension -in @('.ps1', '.vbs')) { Sign-File $f.FullName $SignCert }
207
- }
208
- # The runner's node.exe is the official OpenJS build and already Valid; sign a
209
- # copy only if it is not, so the floor check measures signatures rather than
210
- # the runner image.
211
- $nodeSig = Get-AuthenticodeSignature -LiteralPath (Join-Path $bin 'node.exe')
212
- if ($nodeSig.Status -ne 'Valid') { Sign-File (Join-Path $bin 'node.exe') $SignCert }
463
+ Say ("payload tree $(Split-Path -Leaf $Dest) built in {0:N1}s (realNode=$RealNode)" -f ((Get-Date) - $t0).TotalSeconds)
213
464
  }
214
465
 
215
- function New-InstallTree([string]$Dest) {
466
+ function New-InstallTree([string]$Dest, [switch]$RealNode) {
216
467
  if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
217
468
  $bin = Join-Path $Dest 'bin'
218
469
  $dist = Join-Path $Dest 'lib\edge-sdk\dist'
@@ -220,7 +471,14 @@ function New-InstallTree([string]$Dest) {
220
471
  foreach ($f in @('stop-existing.ps1', 'register-task.ps1', 'install-update.ps1', 'apply-update.ps1', 'run-connector.ps1', 'trillboards-edge-task.vbs')) {
221
472
  Copy-Item -LiteralPath (Join-Path $Deploy $f) -Destination (Join-Path $bin $f) -Force
222
473
  }
223
- Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
474
+ # Same reasoning as the payload tree: only the arms that RUN the connector
475
+ # need a real interpreter, and copying ~110 MB per arm is what blew the job
476
+ # budget the first time this harness ran.
477
+ if ($RealNode) {
478
+ Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
479
+ } elseif ($script:SignedCache -and (Test-Path -LiteralPath (Join-Path $script:SignedCache 'node.exe'))) {
480
+ Copy-Item -LiteralPath (Join-Path $script:SignedCache 'node.exe') -Destination (Join-Path $bin 'node.exe') -Force
481
+ }
224
482
  [System.IO.File]::WriteAllText((Join-Path $dist 'cli.js'), "setInterval(function(){}, 1000);`r`n", (New-Object System.Text.UTF8Encoding($false)))
225
483
  }
226
484
 
@@ -239,10 +497,49 @@ function New-Catalog([string]$Path, [string]$Version, [string]$Sha, [long]$Size)
239
497
  plus whether staged.json exists, which together are the only observable the
240
498
  connector ever acts on.
241
499
  #>
242
- function Invoke-Applier([string]$InstallDir, [string[]]$Args) {
500
+ function Invoke-Applier([string]$InstallDir, [System.Collections.IDictionary]$ApplierParameters) {
243
501
  $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
244
- $all = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $Applier, '-InstallDir', $InstallDir) + $Args
245
- $p = Start-Process -FilePath $ps -ArgumentList $all -NoNewWindow -PassThru -Wait
502
+ # Start-Process flattens -ArgumentList into one command-line string without
503
+ # preserving element boundaries. A value such as the real signer
504
+ # "Trillboards CI AllSigned Harness" therefore arrives at apply-update.ps1
505
+ # as four positional arguments. An array splat is also positional, so it
506
+ # cannot reconstruct names such as -ExpectedVersion after deserialization.
507
+ # Carry a named parameter map as JSON inside an encoded child program instead:
508
+ # the only native-process argument is base64, and the child rebuilds a
509
+ # hashtable that PowerShell binds by parameter name without reparsing values.
510
+ $namedParameters = [ordered]@{ InstallDir = [string]$InstallDir }
511
+ foreach ($name in $ApplierParameters.Keys) {
512
+ if ([string]$name -eq 'InstallDir') { Die 'Invoke-Applier parameters must not override InstallDir' }
513
+ $namedParameters[[string]$name] = $ApplierParameters[$name]
514
+ }
515
+ $invocationJson = [ordered]@{
516
+ applier = [string]$Applier
517
+ parameters = $namedParameters
518
+ } | ConvertTo-Json -Compress
519
+ $invocationBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($invocationJson))
520
+ $childSource = @"
521
+ `$ErrorActionPreference = 'Stop'
522
+ `$decodedInvocation = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$invocationBase64'))
523
+ `$invocation = `$decodedInvocation | ConvertFrom-Json
524
+ `$childApplier = [string]`$invocation.applier
525
+ `$childParameters = @{}
526
+ foreach (`$property in @(`$invocation.parameters.PSObject.Properties)) {
527
+ `$childParameters[`$property.Name] = `$property.Value
528
+ }
529
+ & `$childApplier @childParameters
530
+ exit `$LASTEXITCODE
531
+ "@
532
+ $encodedChild = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childSource))
533
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
534
+ $startInfo.FileName = $ps
535
+ $startInfo.Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedChild"
536
+ $startInfo.UseShellExecute = $false
537
+ $p = [Diagnostics.Process]::new()
538
+ $p.StartInfo = $startInfo
539
+ $null = $p.Start()
540
+ $null = Wait-Bounded -Process $p
541
+ $exitCode = $p.ExitCode
542
+ $p.Close()
246
543
  $updateDir = Join-Path $InstallDir '.auto-update'
247
544
  $outcome = $null
248
545
  $outcomePath = Join-Path $updateDir 'outcome.json'
@@ -259,7 +556,7 @@ function Invoke-Applier([string]$InstallDir, [string[]]$Args) {
259
556
  if ($null -ne $outcome.detail) { $detail = [string]$outcome.detail }
260
557
  }
261
558
  return [pscustomobject]@{
262
- ExitCode = $p.ExitCode
559
+ ExitCode = $exitCode
263
560
  Outcome = $outcome
264
561
  Result = $result
265
562
  Detail = $detail
@@ -269,15 +566,18 @@ function Invoke-Applier([string]$InstallDir, [string[]]$Args) {
269
566
  }
270
567
 
271
568
  function Publish-Payload([string]$TreeDir, [string]$Name) {
569
+ $t0 = Get-Date
272
570
  $zip = Join-Path $Www $Name
273
571
  if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }
274
572
  Add-Type -AssemblyName System.IO.Compression.FileSystem
275
573
  [System.IO.Compression.ZipFile]::CreateFromDirectory($TreeDir, $zip, [System.IO.Compression.CompressionLevel]::Fastest, $false)
276
- return [pscustomobject]@{
574
+ $out = [pscustomobject]@{
277
575
  Path = $zip
278
576
  Bytes = (Get-Item -LiteralPath $zip).Length
279
577
  Sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
280
578
  }
579
+ Say ("zipped $Name : {0:N1} MB in {1:N1}s" -f ($out.Bytes / 1MB), ((Get-Date) - $t0).TotalSeconds)
580
+ return $out
281
581
  }
282
582
 
283
583
  function Get-Shape([string]$Name) {
@@ -316,17 +616,36 @@ try {
316
616
  if (-not (Test-Path -LiteralPath $Applier)) { Die "apply-update.ps1 not found at $Applier" }
317
617
  if (Test-Path -LiteralPath $Root) { Remove-Item -LiteralPath $Root -Recurse -Force }
318
618
  New-Item -ItemType Directory -Force -Path $Root, $Www | Out-Null
319
- Start-Origin
320
-
321
- $goodCert = New-HarnessCert 'Trillboards CI Update Harness'
322
- $otherCert = New-HarnessCert 'Definitely Not Trillboards'
323
- $signer = 'Trillboards CI Update Harness'
619
+ if (($CertificateMintDelaySeconds -gt 0) -and (-not $CertificateDeadlineProbe)) {
620
+ Die 'CertificateMintDelaySeconds is a deadline-probe input, not a full-harness option'
621
+ }
622
+ if ($BoundedWaitFailureProbe) {
623
+ $unstarted = [Diagnostics.Process]::new()
624
+ $null = Wait-Bounded -Process $unstarted -Phase 'bounded wait observer failure probe'
625
+ Die 'bounded wait observer failure probe was reclassified as a process exit'
626
+ }
627
+ if ($CertificateDeadlineProbe) {
628
+ if ($CertificateMintDelaySeconds -le $DeadlineSeconds) {
629
+ Die 'certificate deadline probe requires a mint delay longer than its deadline'
630
+ }
631
+ Say 'deadline probe: starting a deliberately blocked certificate mint'
632
+ $null = New-HarnessCert 'Trillboards CI Deadline Probe'
633
+ Die 'certificate deadline probe was not stopped by the harness deadline'
634
+ }
635
+ Say 'loading the real certificate retained from the AllSigned proof'
636
+ $certificateFixtures = Read-HarnessCertificateFixtures $CertificateFixturePath
637
+ $goodCert = $certificateFixtures.Good
638
+ $signer = 'Trillboards CI AllSigned Harness'
324
639
  $version = '9.9.9'
640
+ Say 'certificates ready'
641
+
642
+ Start-Origin
325
643
 
326
644
  $payloadTree = Join-Path $Root 'payload-src'
327
645
  New-PayloadTree -Dest $payloadTree -SignCert $goodCert
328
646
  $good = Publish-Payload $payloadTree 'payload.zip'
329
- Say ("payload: {0} bytes, sha {1}" -f $good.Bytes, $good.Sha)
647
+ Say ("reference payload: {0} bytes, sha {1}" -f $good.Bytes, $good.Sha)
648
+ Assert-WithinDeadline 'building the reference payload'
330
649
 
331
650
  $catalogGood = Join-Path $Www 'catalog-good.ps1'
332
651
  New-Catalog $catalogGood $version $good.Sha $good.Bytes
@@ -336,10 +655,13 @@ try {
336
655
 
337
656
  # ---- A: HAPPY PATH. The control for every arm below it -------------------
338
657
  Say '--- arm A: happy path (the shared control) ---'
658
+ Assert-WithinDeadline 'arm A: happy path (the shared control)'
339
659
  $installA = Join-Path $Root 'install-a'
340
660
  New-InstallTree $installA
341
- $a = Invoke-Applier $installA @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
342
- '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
661
+ $a = Invoke-Applier $installA ([ordered]@{
662
+ Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-good.ps1"
663
+ ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
664
+ })
343
665
  if (-not $a.Staged) {
344
666
  Die "arm A did not stage (exit $($a.ExitCode), result '$($a.Result)', detail '$($a.Detail)') - every refusal below would be VACUOUS"
345
667
  }
@@ -349,6 +671,7 @@ try {
349
671
 
350
672
  # ---- B: WRONG HASH -------------------------------------------------------
351
673
  Say '--- arm B: the payload does not match the signed hash ---'
674
+ Assert-WithinDeadline 'arm B: the payload does not match the signed hash'
352
675
  $tampered = Join-Path $Www 'payload-tampered.zip'
353
676
  Copy-Item -LiteralPath $good.Path -Destination $tampered -Force
354
677
  $bytes = [System.IO.File]::ReadAllBytes($tampered)
@@ -356,8 +679,10 @@ try {
356
679
  [System.IO.File]::WriteAllBytes($tampered, $bytes)
357
680
  $installB = Join-Path $Root 'install-b'
358
681
  New-InstallTree $installB
359
- $b = Invoke-Applier $installB @('-Stage', '-UpdateUrl', "$BaseUrl/payload-tampered.zip", '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
360
- '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
682
+ $b = Invoke-Applier $installB ([ordered]@{
683
+ Stage = $true; UpdateUrl = "$BaseUrl/payload-tampered.zip"; CatalogUrl = "$BaseUrl/catalog-good.ps1"
684
+ ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
685
+ })
361
686
  if ($b.Staged) { Bad 'arm B staged a payload whose SHA-256 did not match the signed catalog' }
362
687
  elseif ($b.Result -ne 'rejected') { Bad "arm B recorded '$($b.Result)', expected 'rejected'" }
363
688
  elseif ($b.Detail -notmatch 'SHA-256 mismatch') { Bad "arm B rejected for the wrong reason: $($b.Detail)" }
@@ -366,6 +691,7 @@ try {
366
691
 
367
692
  # ---- C: SHORT BODY -------------------------------------------------------
368
693
  Say '--- arm C: the download is short (what a disk filling mid-download looks like) ---'
694
+ Assert-WithinDeadline 'arm C: the download is short (what a disk filling mid-download looks like)'
369
695
  $short = Join-Path $Www 'payload-short.zip'
370
696
  # A real byte[] copy, not a PowerShell range slice: `$bytes[0..n]` boxes every
371
697
  # element into an Object[], which WriteAllBytes will not take and which is
@@ -376,8 +702,10 @@ try {
376
702
  [System.IO.File]::WriteAllBytes($short, $half)
377
703
  $installC = Join-Path $Root 'install-c'
378
704
  New-InstallTree $installC
379
- $c = Invoke-Applier $installC @('-Stage', '-UpdateUrl', "$BaseUrl/payload-short.zip", '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
380
- '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
705
+ $c = Invoke-Applier $installC ([ordered]@{
706
+ Stage = $true; UpdateUrl = "$BaseUrl/payload-short.zip"; CatalogUrl = "$BaseUrl/catalog-good.ps1"
707
+ ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
708
+ })
381
709
  if ($c.Staged) { Bad 'arm C staged a truncated payload' }
382
710
  elseif ($c.Detail -notmatch 'size mismatch') { Bad "arm C rejected for the wrong reason: $($c.Detail)" }
383
711
  elseif ($c.Archive) { Bad 'arm C left the truncated archive on disk' }
@@ -385,6 +713,7 @@ try {
385
713
 
386
714
  # ---- D: UNSIGNED CATALOG -------------------------------------------------
387
715
  Say '--- arm D: the catalog is not signed at all ---'
716
+ Assert-WithinDeadline 'arm D: the catalog is not signed at all'
388
717
  $catalogUnsigned = Join-Path $Www 'catalog-unsigned.ps1'
389
718
  New-Catalog $catalogUnsigned $version $good.Sha $good.Bytes
390
719
  if ((Get-AuthenticodeSignature -LiteralPath $catalogUnsigned).Status -eq 'Valid') {
@@ -392,31 +721,37 @@ try {
392
721
  }
393
722
  $installD = Join-Path $Root 'install-d'
394
723
  New-InstallTree $installD
395
- $d = Invoke-Applier $installD @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-unsigned.ps1",
396
- '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
724
+ $d = Invoke-Applier $installD ([ordered]@{
725
+ Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-unsigned.ps1"
726
+ ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
727
+ })
397
728
  if ($d.Staged) { Bad 'arm D staged against an unsigned catalog' }
398
729
  elseif ($d.Detail -notmatch 'not Valid') { Bad "arm D rejected for the wrong reason: $($d.Detail)" }
399
730
  else { Pass 'an unsigned catalog is rejected' }
400
731
 
401
732
  # ---- E: WRONG PUBLISHER (the sharpest control) ---------------------------
402
733
  Say '--- arm E: the catalog is VALIDLY signed, by somebody else ---'
734
+ Assert-WithinDeadline 'arm E: the catalog is VALIDLY signed, by somebody else'
403
735
  $catalogOther = Join-Path $Www 'catalog-other.ps1'
404
736
  New-Catalog $catalogOther $version $good.Sha $good.Bytes
405
- Sign-File $catalogOther $otherCert
737
+ Sign-File $catalogOther $goodCert
406
738
  $otherStatus = (Get-AuthenticodeSignature -LiteralPath $catalogOther).Status
407
739
  if ($otherStatus -ne 'Valid') {
408
740
  Die "arm E is VACUOUS: the other publisher's signature is '$otherStatus', not Valid - a status check alone would reject it and prove nothing about the subject check"
409
741
  }
410
742
  $installE = Join-Path $Root 'install-e'
411
743
  New-InstallTree $installE
412
- $e = Invoke-Applier $installE @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-other.ps1",
413
- '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
744
+ $e = Invoke-Applier $installE ([ordered]@{
745
+ Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-other.ps1"
746
+ ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = 'Definitely Not Trillboards'
747
+ })
414
748
  if ($e.Staged) { Bad 'arm E staged a catalog signed by an unexpected publisher' }
415
749
  elseif ($e.Detail -notmatch 'unexpected publisher') { Bad "arm E rejected for the wrong reason: $($e.Detail)" }
416
750
  else { Pass 'a VALID signature from the wrong publisher is rejected on the subject' }
417
751
 
418
752
  # ---- F: SIGNED THEN TAMPERED --------------------------------------------
419
753
  Say '--- arm F: the catalog was signed, then edited ---'
754
+ Assert-WithinDeadline 'arm F: the catalog was signed, then edited'
420
755
  $catalogEdited = Join-Path $Www 'catalog-edited.ps1'
421
756
  Copy-Item -LiteralPath $catalogGood -Destination $catalogEdited -Force
422
757
  Add-Content -LiteralPath $catalogEdited -Value '# an edit after signing'
@@ -424,20 +759,25 @@ try {
424
759
  if ($editedStatus -eq 'Valid') { Die 'arm F is VACUOUS: the edited catalog still reports Valid' }
425
760
  $installF = Join-Path $Root 'install-f'
426
761
  New-InstallTree $installF
427
- $f = Invoke-Applier $installF @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-edited.ps1",
428
- '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
762
+ $f = Invoke-Applier $installF ([ordered]@{
763
+ Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-edited.ps1"
764
+ ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
765
+ })
429
766
  if ($f.Staged) { Bad 'arm F staged a catalog that was edited after signing' }
430
767
  else { Pass "a catalog edited after signing is rejected ($editedStatus)" }
431
768
 
432
769
  # ---- G: DISK PREFLIGHT ---------------------------------------------------
433
770
  Say '--- arm G: the declared payload does not fit on the volume ---'
771
+ Assert-WithinDeadline 'arm G: the declared payload does not fit on the volume'
434
772
  $huge = Join-Path $Www 'catalog-huge.ps1'
435
773
  New-Catalog $huge $version $good.Sha 900000000000
436
774
  Sign-File $huge $goodCert
437
775
  $installG = Join-Path $Root 'install-g'
438
776
  New-InstallTree $installG
439
- $g = Invoke-Applier $installG @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-huge.ps1",
440
- '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
777
+ $g = Invoke-Applier $installG ([ordered]@{
778
+ Stage = $true; UpdateUrl = $urlZip; CatalogUrl = "$BaseUrl/catalog-huge.ps1"
779
+ ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
780
+ })
441
781
  if ($g.Staged) { Bad 'arm G staged a payload that cannot fit' }
442
782
  elseif ($g.Detail -notmatch 'free disk space') { Bad "arm G rejected for the wrong reason: $($g.Detail)" }
443
783
  elseif ($g.Archive) { Bad 'arm G downloaded before checking that the payload would fit' }
@@ -445,6 +785,7 @@ try {
445
785
 
446
786
  # ---- H: UNSIGNED SCRIPT INSIDE THE PAYLOAD -------------------------------
447
787
  Say '--- arm H: the payload contains an unsigned script ---'
788
+ Assert-WithinDeadline 'arm H: the payload contains an unsigned script'
448
789
  $rogueTree = Join-Path $Root 'payload-rogue'
449
790
  New-PayloadTree -Dest $rogueTree -SignCert $goodCert -WithUnsignedScript
450
791
  $rogue = Publish-Payload $rogueTree 'payload-rogue.zip'
@@ -453,8 +794,10 @@ try {
453
794
  Sign-File $catalogRogue $goodCert
454
795
  $installH = Join-Path $Root 'install-h'
455
796
  New-InstallTree $installH
456
- $h = Invoke-Applier $installH @('-Stage', '-UpdateUrl', "$BaseUrl/payload-rogue.zip", '-CatalogUrl', "$BaseUrl/catalog-rogue.ps1",
457
- '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
797
+ $h = Invoke-Applier $installH ([ordered]@{
798
+ Stage = $true; UpdateUrl = "$BaseUrl/payload-rogue.zip"; CatalogUrl = "$BaseUrl/catalog-rogue.ps1"
799
+ ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
800
+ })
458
801
  if ($h.Staged) { Bad 'arm H staged a payload containing an unsigned script' }
459
802
  elseif ($h.Detail -notmatch 'unsigned or invalid') { Bad "arm H rejected for the wrong reason: $($h.Detail)" }
460
803
  else { Pass 'a payload carrying an unsigned script is rejected' }
@@ -468,17 +811,19 @@ try {
468
811
  )) {
469
812
  Say "--- arm $($arm.Name): commit over a RUNNING connector, with a payload that would re-register logon-only ---"
470
813
  $installJ = Join-Path $Root "install-$($arm.Name)"
471
- New-InstallTree $installJ
814
+ New-InstallTree $installJ -RealNode
472
815
  $downTree = Join-Path $Root "payload-down-$($arm.Name)"
473
- New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask
816
+ New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask -RealNode
474
817
  $down = Publish-Payload $downTree "payload-down-$($arm.Name).zip"
475
818
  $catDown = Join-Path $Www "catalog-down-$($arm.Name).ps1"
476
819
  New-Catalog $catDown $version $down.Sha $down.Bytes
477
820
  Sign-File $catDown $goodCert
478
821
 
479
- $staged = Invoke-Applier $installJ @('-Stage', '-UpdateUrl', "$BaseUrl/payload-down-$($arm.Name).zip",
480
- '-CatalogUrl', "$BaseUrl/catalog-down-$($arm.Name).ps1", '-ExpectedVersion', $version,
481
- '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
822
+ $staged = Invoke-Applier $installJ ([ordered]@{
823
+ Stage = $true; UpdateUrl = "$BaseUrl/payload-down-$($arm.Name).zip"
824
+ CatalogUrl = "$BaseUrl/catalog-down-$($arm.Name).ps1"; ExpectedVersion = $version
825
+ CurrentVersion = '9.9.8'; ExpectedSigner = $signer
826
+ })
482
827
  if (-not $staged.Staged) { Die "arm $($arm.Name) could not stage: $($staged.Detail)" }
483
828
 
484
829
  Register-HeadlessTask $installJ
@@ -496,9 +841,9 @@ try {
496
841
  Die "arm $($arm.Name) is VACUOUS: node.exe was not locked while the connector was running, so a successful swap proves nothing"
497
842
  }
498
843
 
499
- $args = @('-Commit', '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8')
500
- if ($arm.Skip) { $args += '-SkipTaskShapeRestore' }
501
- $j = Invoke-Applier $installJ $args
844
+ $commitParameters = [ordered]@{ Commit = $true; ExpectedVersion = $version; CurrentVersion = '9.9.8' }
845
+ if ($arm.Skip) { $commitParameters['SkipTaskShapeRestore'] = $true }
846
+ $j = Invoke-Applier $installJ $commitParameters
502
847
  try { Stop-Process -Id $running.Id -Force -ErrorAction SilentlyContinue } catch {}
503
848
 
504
849
  $after = Get-Shape $TaskName
@@ -521,8 +866,10 @@ try {
521
866
  # ---- K: the would-downgrade preflight, from an UNELEVATED caller ---------
522
867
  if ($SkipUnelevatedArm) {
523
868
  Say '--- arm K SKIPPED by request ---'
869
+ Assert-WithinDeadline 'arm K SKIPPED by request'
524
870
  } else {
525
871
  Say '--- arm K: an unelevated caller must REFUSE rather than downgrade ---'
872
+ Assert-WithinDeadline 'arm K: an unelevated caller must REFUSE rather than downgrade'
526
873
  $installK = Join-Path $Root 'install-k'
527
874
  New-InstallTree $installK
528
875
  $downTree = Join-Path $Root 'payload-k'
@@ -531,8 +878,10 @@ try {
531
878
  $catK = Join-Path $Www 'catalog-k.ps1'
532
879
  New-Catalog $catK $version $down.Sha $down.Bytes
533
880
  Sign-File $catK $goodCert
534
- $stagedK = Invoke-Applier $installK @('-Stage', '-UpdateUrl', "$BaseUrl/payload-k.zip", '-CatalogUrl', "$BaseUrl/catalog-k.ps1",
535
- '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
881
+ $stagedK = Invoke-Applier $installK ([ordered]@{
882
+ Stage = $true; UpdateUrl = "$BaseUrl/payload-k.zip"; CatalogUrl = "$BaseUrl/catalog-k.ps1"
883
+ ExpectedVersion = $version; CurrentVersion = '9.9.8'; ExpectedSigner = $signer
884
+ })
536
885
  if (-not $stagedK.Staged) { Die "arm K could not stage: $($stagedK.Detail)" }
537
886
  Register-HeadlessTask $installK
538
887
  $beforeK = Get-Shape $TaskName
@@ -563,8 +912,9 @@ try {
563
912
  $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
564
913
  # Materialise the profile once, so the run under test is not measuring
565
914
  # first-logon profile creation.
566
- Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $cred -LoadUserProfile `
567
- -WorkingDirectory 'C:\' -Wait -ErrorAction Stop
915
+ $warm = Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $cred -LoadUserProfile `
916
+ -WorkingDirectory 'C:\' -PassThru -ErrorAction Stop
917
+ $null = Wait-Bounded -Process $warm -Phase 'arm K: materialising the local profile'
568
918
 
569
919
  # Drive the COPY inside the install tree, not the one in the repo
570
920
  # workspace: the ACL above covers the install tree, and the runner's
@@ -572,10 +922,11 @@ try {
572
922
  $applierCopy = Join-Path $installK 'bin\apply-update.ps1'
573
923
  $outK = Join-Path $Root 'k-stdout.txt'
574
924
  $errK = Join-Path $Root 'k-stderr.txt'
575
- $null = Start-Process -FilePath $ps -Credential $cred -LoadUserProfile -WorkingDirectory 'C:\' -PassThru -Wait `
925
+ $pk = Start-Process -FilePath $ps -Credential $cred -LoadUserProfile -WorkingDirectory 'C:\' -PassThru `
576
926
  -RedirectStandardOutput $outK -RedirectStandardError $errK `
577
927
  -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $applierCopy,
578
928
  '-InstallDir', $installK, '-Commit', '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8')
929
+ $null = Wait-Bounded -Process $pk
579
930
  foreach ($f in @($outK, $errK)) {
580
931
  if (Test-Path -LiteralPath $f) {
581
932
  $t = Get-Content -Raw -LiteralPath $f