@trillboards/edge-sdk 0.2.16 → 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.
Files changed (36) hide show
  1. package/THIRD-PARTY-NOTICES.md +51 -0
  2. package/deploy/macos/build-macos-pkg.sh +35 -5
  3. package/deploy/macos/scripts/setup-litertlm.sh +35 -10
  4. package/deploy/windows/README.md +17 -4
  5. package/deploy/windows/build-windows.ps1 +25 -2
  6. package/deploy/windows/ci/list-shipped-scripts.ps1 +58 -0
  7. package/deploy/windows/ci/tb-canary.ps1 +18 -0
  8. package/deploy/windows/ci/tb-canary.vbs +17 -0
  9. package/deploy/windows/ci/verify-allsigned-enforcement.ps1 +608 -0
  10. package/deploy/windows/e2e/test-headless-principal.ps1 +632 -0
  11. package/deploy/windows/ensure-sensing.ps1 +91 -1
  12. package/deploy/windows/register-litertlm-task.ps1 +114 -15
  13. package/deploy/windows/register-task.ps1 +162 -12
  14. package/deploy/windows/setup-litertlm.ps1 +78 -32
  15. package/deploy/windows/stop-existing.ps1 +2 -2
  16. package/deploy/windows/trillboards-hidden-run.vbs +3 -3
  17. package/deploy/windows/trillboards-setup.ps1 +2 -2
  18. package/dist/CloudSensingManager.d.ts +13 -4
  19. package/dist/CloudSensingManager.d.ts.map +1 -1
  20. package/dist/CloudSensingManager.js +6 -0
  21. package/dist/CloudSensingManager.js.map +1 -1
  22. package/dist/EdgeAgent.js +1 -1
  23. package/dist/LiteRtVlmProvider.d.ts +108 -45
  24. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  25. package/dist/LiteRtVlmProvider.js +124 -84
  26. package/dist/LiteRtVlmProvider.js.map +1 -1
  27. package/dist/diagnose.d.ts +1 -1
  28. package/dist/diagnose.js +1 -1
  29. package/dist/localSenseE2eHarness.d.ts +17 -4
  30. package/dist/localSenseE2eHarness.d.ts.map +1 -1
  31. package/dist/localSenseE2eHarness.js +11 -1
  32. package/dist/localSenseE2eHarness.js.map +1 -1
  33. package/dist/sensingRunMode.d.ts.map +1 -1
  34. package/dist/sensingRunMode.js +49 -31
  35. package/dist/sensingRunMode.js.map +1 -1
  36. package/package.json +1 -1
@@ -40,7 +40,97 @@ if (-not $task) {
40
40
  }
41
41
  }
42
42
 
43
- if ($task.State -eq 'Disabled') {
43
+ # MIGRATION. Installs made before the headless fix carry a logon-only task, so
44
+ # they still go dark after an unattended reboot even though the shipped scripts
45
+ # on disk are now correct. There is no auto-updater, so the installer is not the
46
+ # only path that has to repair this. Re-register when -- and only when -- the
47
+ # registered task has NO logon-free start path: after one repair this is a
48
+ # no-op, so it cannot churn a healthy install or interrupt sensing on every
49
+ # wizard close.
50
+ #
51
+ # Both shapes count as repaired. register-task.ps1 registers a boot trigger only
52
+ # when it can (Microsoft allows those to Administrators only) and otherwise
53
+ # lands on a repeating trigger, which is the shape a normal no-admin install
54
+ # gets. Keying this check on <BootTrigger> alone would re-register that install
55
+ # on EVERY wizard close.
56
+ $needsHeadlessRepair = $false
57
+ try {
58
+ $definition = Export-ScheduledTask -TaskName $TaskName
59
+ $needsHeadlessRepair = ($definition -notmatch "<BootTrigger>") -and ($definition -notmatch "<Repetition>")
60
+ } catch {
61
+ Write-Host "Could not read the current task definition: $($_.Exception.Message)"
62
+ }
63
+ if ($needsHeadlessRepair) {
64
+ $register = Join-Path $InstallDir "bin\register-task.ps1"
65
+ if (Test-Path $register) {
66
+ Write-Host "Upgrading '$TaskName' from a logon-only registration to a startup one."
67
+ # Non-fatal: the final runnable check below is the loud signal. A failed
68
+ # repair must not be what stops a customer closing the setup window.
69
+ try { & $register -InstallDir $InstallDir | Out-Null }
70
+ catch { Write-Host "Headless re-registration failed: $($_.Exception.Message)" }
71
+ $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
72
+ }
73
+ }
74
+
75
+ # THE ONE UAC PROMPT. Everything above works unelevated, but the PRINCIPAL does
76
+ # not: measured on windows-latest (run 31836760844), a standard user registering
77
+ # for ITSELF is refused S4U with "Access is denied", because S4U performs a batch
78
+ # logon and Task Scheduler only grants SeBatchLogonRight when the CALLER is an
79
+ # administrator. An unelevated install therefore lands on 'Interactive + repeat',
80
+ # which still needs somebody logged on -- i.e. the reboot defect is NOT fixed.
81
+ # One elevation is the entire difference, so ask for it HERE: this runs at setup
82
+ # -wizard close, with the customer present, and register-task.ps1 repairs the
83
+ # model-server task in the same elevated pass so this is the only prompt.
84
+ #
85
+ # Every guard below exists to make sure we never prompt when it cannot help:
86
+ # - already headless -> nothing to gain
87
+ # - not UserInteractive -> no desktop to show a dialog on; -Verb RunAs
88
+ # would throw or hang an unattended install
89
+ # - not admin-capable -> UAC would show an over-the-shoulder CREDENTIAL
90
+ # prompt to a standard user who cannot satisfy it
91
+ # - already elevated -> register-task.ps1 above already used it
92
+ # A declined or failed prompt is NOT an error: the unelevated registration from
93
+ # the ladder is still in place and still better than what shipped before.
94
+ function Test-IsElevated {
95
+ $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
96
+ (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole(
97
+ [System.Security.Principal.WindowsBuiltInRole]::Administrator)
98
+ }
99
+ function Test-AdminCapable {
100
+ # On a filtered admin token the Administrators SID is still present (deny-only),
101
+ # which is exactly the population for whom one UAC click changes the outcome.
102
+ foreach ($group in [System.Security.Principal.WindowsIdentity]::GetCurrent().Groups) {
103
+ if ($group.Value -eq 'S-1-5-32-544') { return $true }
104
+ }
105
+ return $false
106
+ }
107
+
108
+ $isHeadless = $false
109
+ try {
110
+ $current = Export-ScheduledTask -TaskName $TaskName
111
+ $isHeadless = (($current -match "<BootTrigger>") -or ($current -match "<Repetition>")) -and
112
+ ($current -notmatch "<LogonType>InteractiveToken</LogonType>")
113
+ } catch {}
114
+
115
+ if ((-not $isHeadless) -and [Environment]::UserInteractive -and (Test-AdminCapable) -and -not (Test-IsElevated)) {
116
+ $register = Join-Path $InstallDir "bin\register-task.ps1"
117
+ if (Test-Path $register) {
118
+ Write-Host "Asking for one elevation so sensing can resume after an unattended restart."
119
+ try {
120
+ $psExe = Join-Path $env:WINDIR "System32\WindowsPowerShell\v1.0\powershell.exe"
121
+ $psArgs = @(
122
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden',
123
+ '-File', ('"' + $register + '"'), '-InstallDir', ('"' + $InstallDir + '"')
124
+ )
125
+ Start-Process -FilePath $psExe -ArgumentList $psArgs -Verb RunAs -WindowStyle Hidden -Wait -ErrorAction Stop
126
+ $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
127
+ } catch {
128
+ Write-Host "Elevated re-registration was not completed ($($_.Exception.Message)). Keeping the unelevated registration."
129
+ }
130
+ }
131
+ }
132
+
133
+ if ($task -and $task.State -eq 'Disabled') {
44
134
  Enable-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null
45
135
  $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
46
136
  }
@@ -1,10 +1,20 @@
1
1
  <#
2
- register-litertlm-task.ps1 — register the per-user Scheduled Task that runs the
3
- LiteRT-LM on-device VLM server (Gemma 4 E2B) at logon, with crash-restart.
4
- Per-user + Interactive logon type → NO admin/UAC (mirrors the macOS
5
- com.trillboards.litertlm LaunchAgent: KeepAlive=true + ThrottleInterval=30).
6
- The connector's LiteRtVlmProvider talks to the OpenAI-compatible endpoint on
7
- 127.0.0.1:<Port>. Invoked by setup-litertlm.ps1.
2
+ register-litertlm-task.ps1 -- register the Scheduled Task that runs the
3
+ LiteRT-LM on-device VLM server (Gemma 4 E2B) with crash-restart. It runs the
4
+ SAME degradation ladder as register-task.ps1 and must land on the same rung --
5
+ this task is not optional furniture, because the connector's default
6
+ auto-sense mode WAITS on the OpenAI-compatible endpoint at 127.0.0.1:<Port>,
7
+ so a connector that comes back headless while this one still waits for a logon
8
+ is a connector that runs and senses nothing.
9
+
10
+ This script never prompts. Headless costs one elevation on Windows (S4U needs
11
+ the account to hold SeBatchLogonRight, which Task Scheduler grants only to an
12
+ administrator caller), and a second UAC dialog in one install is not
13
+ acceptable -- so ensure-sensing.ps1 owns the single prompt and register-task.ps1
14
+ repairs THIS task inside that same elevated pass. Unelevated, both land on
15
+ 'Interactive + repeat' together, which is consistent rather than headless.
16
+ Mirrors the macOS com.trillboards.litertlm LaunchAgent (KeepAlive=true +
17
+ ThrottleInterval=30). Invoked by setup-litertlm.ps1.
8
18
  #>
9
19
  param(
10
20
  [Parameter(Mandatory = $true)][string]$LitertLmPath,
@@ -14,29 +24,118 @@ $ErrorActionPreference = "Stop"
14
24
 
15
25
  $TaskName = "TrillboardsLiteRtLm"
16
26
  $UserId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
17
- # litert-lm serve --host 127.0.0.1 --port <port> (loopback only — the connector
27
+ # litert-lm serve --host 127.0.0.1 --port <port> (loopback only -- the connector
18
28
  # is local; never expose the model server off-box).
19
29
  $argument = 'serve --host 127.0.0.1 --port ' + $Port
20
30
  $Action = New-ScheduledTaskAction -Execute $LitertLmPath -Argument $argument
21
- $Trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId
31
+
32
+ # NOT AT LOGON -- see register-task.ps1 for the full rationale, including why
33
+ # the REPEATING trigger (not the boot trigger) is the load-bearing one: only an
34
+ # Administrators-group member can create a task with a boot trigger, and this
35
+ # runs from a no-admin installer.
36
+ $RepeatTrigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) `
37
+ -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650)
38
+ # The boot delay is longer here than the connector's 30s because this process
39
+ # memory-maps a ~2.4 GB model off a disk that is still busy with the rest of boot.
40
+ $BootTrigger = New-ScheduledTaskTrigger -AtStartup
41
+ # Best-effort, exactly as in register-task.ps1: never fail registration over it.
42
+ try { $BootTrigger.Delay = "PT1M" } catch { Write-Warning "Could not set the boot-trigger delay: $($_.Exception.Message)" }
43
+ # Fastest re-arm when someone does log on.
44
+ $LogonTrigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId
45
+
22
46
  # RestartCount/Interval == the plist's ThrottleInterval crash-loop guard (e.g.
23
47
  # while the ~2.4 GB model is still importing on first run); Zero time limit ==
24
- # the always-on KeepAlive serve endpoint.
48
+ # the always-on KeepAlive serve endpoint. MultipleInstances IgnoreNew matters
49
+ # now that several triggers exist: a second `serve` would fail to bind $Port anyway.
25
50
  $Settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) `
26
51
  -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable `
52
+ -MultipleInstances IgnoreNew `
27
53
  -ExecutionTimeLimit ([TimeSpan]::Zero)
28
- $Principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Limited
29
54
 
30
- Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
31
- Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings `
32
- -Principal $Principal -Description "Trillboards LiteRT-LM - on-device Gemma VLM server (:$Port)" | Out-Null
55
+ # S4U, NOT SystemAccount: litert-lm.exe itself lives under the user profile
56
+ # ($env:USERPROFILE\.local\bin, setup-litertlm.ps1:23) and loads the model from
57
+ # $env:USERPROFILE\.litert-lm (:25). SYSTEM would resolve both to
58
+ # C:\Windows\system32\config\systemprofile and the server would not start at all.
59
+ function New-LitertPrincipal([string]$LogonType) {
60
+ New-ScheduledTaskPrincipal -UserId $UserId -LogonType $LogonType -RunLevel Limited
61
+ }
62
+
63
+ $Description = "Trillboards LiteRT-LM - on-device Gemma VLM server (:$Port)"
64
+
65
+ # Same degradation ladder as register-task.ps1, for the same reasons.
66
+ $Attempts = @(
67
+ @{ Label = "S4U + boot + repeat"; LogonType = "S4U"; Triggers = @($BootTrigger, $RepeatTrigger, $LogonTrigger) },
68
+ @{ Label = "S4U + repeat"; LogonType = "S4U"; Triggers = @($RepeatTrigger, $LogonTrigger) },
69
+ @{ Label = "Interactive + repeat"; LogonType = "Interactive"; Triggers = @($RepeatTrigger, $LogonTrigger) },
70
+ @{ Label = "Interactive + logon only"; LogonType = "Interactive"; Triggers = @($LogonTrigger) }
71
+ )
72
+
73
+ # STOP THE RUNNING SERVE BEFORE RE-REGISTERING, and wait for it to actually go.
74
+ #
75
+ # Unregistering a task is not documented to terminate a running instance, and
76
+ # `litert-lm serve` is a long-lived process holding :$Port. Left alive it keeps
77
+ # ANSWERING - so a version upgrade would install the new CLI, re-register the
78
+ # task, fail to bind the port, and leave the OLD binary serving while every
79
+ # health probe passed. That is precisely how a host could sit on a litert-lm
80
+ # that rejects the `,gpu` model id after we thought we had repaired it.
81
+ #
82
+ # Done ONCE, before the degradation ladder below: the ladder may unregister and
83
+ # re-register several times, and killing the serve on each attempt would only
84
+ # lengthen the window with no model server.
85
+ Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
86
+
87
+ # Belt and braces: kill any litert-lm process still listening on the port. Scoped
88
+ # to THIS user's processes whose command line carries our port, so a developer's
89
+ # unrelated python is never touched.
90
+ try {
91
+ Get-CimInstance Win32_Process -Filter "Name = 'python.exe' OR Name = 'litert-lm.exe' OR Name = 'pythonw.exe'" -ErrorAction SilentlyContinue |
92
+ Where-Object { $_.CommandLine -and $_.CommandLine -match 'litert-lm' -and $_.CommandLine -match ('--port\s+' + [regex]::Escape($Port)) } |
93
+ ForEach-Object {
94
+ Write-Host "stopping previous litert-lm serve (pid $($_.ProcessId)) before re-registering"
95
+ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
96
+ }
97
+ } catch { }
98
+
99
+ # Wait for the port to free, so the Start-ScheduledTask below does not race a
100
+ # dying serve.
101
+ for ($i = 0; $i -lt 15; $i++) {
102
+ $inUse = $null
103
+ try { $inUse = Get-NetTCPConnection -LocalPort ([int]$Port) -State Listen -ErrorAction SilentlyContinue } catch { }
104
+ if (-not $inUse) { break }
105
+ Start-Sleep -Seconds 1
106
+ }
107
+
108
+ $Landed = $null
109
+ foreach ($attempt in $Attempts) {
110
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
111
+ try {
112
+ Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $attempt.Triggers -Settings $Settings `
113
+ -Principal (New-LitertPrincipal $attempt.LogonType) -Description $Description -ErrorAction Stop | Out-Null
114
+ $Landed = $attempt
115
+ break
116
+ } catch {
117
+ Write-Host "Registration attempt '$($attempt.Label)' was refused: $($_.Exception.Message)"
118
+ }
119
+ }
120
+ if (-not $Landed) {
121
+ Write-Error "Scheduled task '$TaskName' could not be registered in ANY supported shape."
122
+ exit 1
123
+ }
33
124
 
34
125
  $registered = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
35
126
  if (-not $registered) {
36
127
  Write-Error "Scheduled task '$TaskName' did NOT register (Get-ScheduledTask found nothing)."
37
128
  exit 1
38
129
  }
130
+ $definition = Export-ScheduledTask -TaskName $TaskName
131
+ $logonType = if ($definition -match "<LogonType>([^<]+)</LogonType>") { $Matches[1] } else { "unknown" }
132
+ if (($definition -notmatch "<BootTrigger>") -and ($definition -notmatch "<Repetition>")) {
133
+ Write-Warning "Scheduled task '$TaskName' registered with a LOGON-ONLY trigger - the connector would come back after a reboot with no model server."
134
+ }
135
+ if ($logonType -eq "InteractiveToken") {
136
+ Write-Warning "Scheduled task '$TaskName' registered with an INTERACTIVE principal - the model server will only run while $UserId is logged on."
137
+ }
39
138
 
40
- # Start it now (don't wait for the next logon) so first-run sensing has a server.
139
+ # Start it now (don't wait for the next boot) so first-run sensing has a server.
41
140
  Start-ScheduledTask -TaskName $TaskName
42
- Write-Host "Registered scheduled task '$TaskName' (litert-lm serve on :$Port, restarts on failure)."
141
+ Write-Host "Registered scheduled task '$TaskName' (litert-lm serve on :$Port, shape '$($Landed.Label)', logon type $logonType, restarts on failure)."
@@ -1,8 +1,9 @@
1
1
  <#
2
- register-task.ps1 — register the per-user Scheduled Task that runs the
3
- Trillboards local-first connector at logon (with crash-restart). Per-user +
4
- Interactive logon type so NO admin/UAC is required (mirrors the home-domain,
5
- no-sudo macOS LaunchAgent). Invoked by the NSIS installer post-copy.
2
+ register-task.ps1 -- register the Scheduled Task that runs the Trillboards
3
+ local-first connector HEADLESS: an AtStartup trigger plus an S4U principal, so
4
+ sensing is back within a minute of an unattended Windows Update reboot with
5
+ NOBODY logged on. Still no admin/UAC -- S4U registers for the CURRENT user and
6
+ stores no password. Invoked by the NSIS installer post-copy.
6
7
  #>
7
8
  param(
8
9
  [Parameter(Mandatory = $true)][string]$InstallDir,
@@ -22,18 +23,108 @@ $wscript = Join-Path $env:WINDIR "System32\wscript.exe"
22
23
  # crashes without exposing cmd.exe or PowerShell to the customer.
23
24
  $argument = '"' + $taskHost + '" "' + $Mode + '"'
24
25
  $Action = New-ScheduledTaskAction -Execute $wscript -Argument $argument -WorkingDirectory $InstallDir
25
- $Trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId
26
+
27
+ # NOT AT LOGON. A store back-office PC reboots itself for Windows Update and
28
+ # nobody signs in for days. With the old -AtLogOn trigger sensing simply stopped
29
+ # until someone logged in, and nothing reported it: the machine is up, the
30
+ # customer sees no error, and we have no coverage.
31
+ #
32
+ # The REPEATING trigger is the load-bearing one, not the boot trigger.
33
+ # Microsoft documents that only a member of the Administrators group can create
34
+ # a task with a boot trigger, and installer.nsi:33 is RequestExecutionLevel
35
+ # user -- so on the supported no-admin path -AtStartup can be refused outright.
36
+ # A -Once trigger with a repetition needs no admin, and under the S4U principal
37
+ # it starts the connector with NOBODY logged on within one interval of a
38
+ # reboot. MultipleInstances IgnoreNew (below) makes every repeat a no-op while
39
+ # the connector is already running, so this costs nothing at steady state.
40
+ $RepeatTrigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) `
41
+ -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650)
42
+ # The boot trigger is a LATENCY optimisation on top: ~30s after boot instead of
43
+ # up to 5 minutes, for installs that do happen to run elevated. The ladder below
44
+ # DROPS it rather than failing when it is refused.
45
+ $BootTrigger = New-ScheduledTaskTrigger -AtStartup
46
+ # Best-effort: the delay is a nicety, StartWhenAvailable + RestartCount are what
47
+ # actually cover a slow boot. Never let it be the thing that fails registration.
48
+ try { $BootTrigger.Delay = "PT30S" } catch { Write-Warning "Could not set the boot-trigger delay: $($_.Exception.Message)" }
49
+ # Kept as a trigger, not a replacement. It is the fastest re-arm when someone
50
+ # does log on, which is the one thing the old logon-only registration was good at.
51
+ $LogonTrigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId
52
+
53
+ # MultipleInstances IgnoreNew is load-bearing now that two triggers exist:
54
+ # logging on while the boot instance is running must NOT start a second
55
+ # connector against the same cameras, config and log file.
26
56
  $Settings = New-ScheduledTaskSettingsSet -RestartCount 99 -RestartInterval (New-TimeSpan -Minutes 1) `
27
57
  -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable `
28
- -ExecutionTimeLimit ([TimeSpan]::Zero) # no time limit — sensing runs continuously
29
- $Principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Limited
58
+ -MultipleInstances IgnoreNew `
59
+ -ExecutionTimeLimit ([TimeSpan]::Zero) # no time limit -- sensing runs continuously
60
+
61
+ # WHY S4U AND NOT SYSTEM. S4U ("run whether the user is logged on or not", no
62
+ # stored password) is the only logon type that runs with no interactive session
63
+ # AND keeps the connector inside the SAME user profile. That second half is not
64
+ # a nicety: the device identity, the paired data dir and the ~2.4 GB Gemma model
65
+ # all resolve through os.homedir()/$env:USERPROFILE --
66
+ # edge-core/src/DeviceIdentity.ts:17 os.homedir()/.trillboards
67
+ # edge-sdk/src/config.ts:502 dataDir = USERPROFILE/.trillboards
68
+ # setup-litertlm.ps1:23,25 USERPROFILE/.local/bin, USERPROFILE/.litert-lm
69
+ # Under SYSTEM or NETWORK SERVICE that root becomes
70
+ # C:\Windows\system32\config\systemprofile, so the connector would start at boot,
71
+ # find no device token and no model, and fail while looking alive -- strictly
72
+ # worse than the outage it replaces. SYSTEM also needs admin to register, which
73
+ # would put UAC in front of a deliberately no-admin installer.
74
+ # Password logon is rejected outright: it needs the customer's password at
75
+ # install time and leaves a reusable credential in the task store.
76
+ # S4U's documented limitation is that the token carries no NETWORK credentials.
77
+ # That costs us nothing: the connector only makes unauthenticated-at-the-Windows
78
+ # -layer connections -- outbound TLS to api.trillboards.com, RTSP/ONVIF to LAN
79
+ # cameras (camera creds live in trillboards.config.json, not in the Windows
80
+ # token), a spawned ffmpeg, and loopback to litert-lm on 127.0.0.1.
81
+ function New-ConnectorPrincipal([string]$LogonType) {
82
+ New-ScheduledTaskPrincipal -UserId $UserId -LogonType $LogonType -RunLevel Limited
83
+ }
30
84
 
31
- Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
32
- Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings `
33
- -Principal $Principal -Description "Trillboards Edge - CCTV $Mode connector" | Out-Null
85
+ $Description = "Trillboards Edge - CCTV $Mode connector"
86
+
87
+ # DEGRADATION LADDER. Each rung drops exactly one capability that a locked-down
88
+ # or non-elevated machine can refuse, in order of how much it costs us. The
89
+ # first rung that registers wins.
90
+ #
91
+ # Measured on windows-latest (run 31836760844), NOT assumed:
92
+ # elevated -> rung 1. Both capabilities available.
93
+ # unelevated -> rung 3. BOTH S4U rungs are refused with "Access is denied",
94
+ # because S4U performs a batch logon and Task Scheduler grants
95
+ # SeBatchLogonRight only to an administrator CALLER (boot
96
+ # triggers are Administrators-only for the same reason).
97
+ # So rung 3 is what a plain no-admin install gets, and it is NOT headless: it
98
+ # still needs somebody logged on. It is nonetheless better than the pre-fix
99
+ # shape, because the repetition revives a connector that died while a user was
100
+ # logged on. ensure-sensing.ps1 asks for the one elevation that lifts a machine
101
+ # from rung 3 to rung 1. Rung 4 exists only so a machine can never be left with
102
+ # NO task at all -- a total sensing outage rather than a degraded one.
103
+ $Attempts = @(
104
+ @{ Label = "S4U + boot + repeat"; LogonType = "S4U"; Triggers = @($BootTrigger, $RepeatTrigger, $LogonTrigger) },
105
+ @{ Label = "S4U + repeat"; LogonType = "S4U"; Triggers = @($RepeatTrigger, $LogonTrigger) },
106
+ @{ Label = "Interactive + repeat"; LogonType = "Interactive"; Triggers = @($RepeatTrigger, $LogonTrigger) },
107
+ @{ Label = "Interactive + logon only"; LogonType = "Interactive"; Triggers = @($LogonTrigger) }
108
+ )
109
+
110
+ $Landed = $null
111
+ foreach ($attempt in $Attempts) {
112
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
113
+ try {
114
+ Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $attempt.Triggers -Settings $Settings `
115
+ -Principal (New-ConnectorPrincipal $attempt.LogonType) -Description $Description -ErrorAction Stop | Out-Null
116
+ $Landed = $attempt
117
+ break
118
+ } catch {
119
+ Write-Host "Registration attempt '$($attempt.Label)' was refused: $($_.Exception.Message)"
120
+ }
121
+ }
122
+ if (-not $Landed) {
123
+ throw "Scheduled task '$TaskName' could not be registered in ANY supported shape."
124
+ }
34
125
 
35
126
  # VERIFY the registration landed instead of trusting Register-ScheduledTask's
36
- # silence — a silently-missing task is exactly how a real 16-camera install
127
+ # silence -- a silently-missing task is exactly how a real 16-camera install
37
128
  # ended up paired with zero frames (the pairing helper then claimed success).
38
129
  # Callers (installer.nsi, trillboards-pair.cmd's self-repair) branch on our
39
130
  # exit code, so fail LOUDLY here.
@@ -41,4 +132,63 @@ $registered = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinu
41
132
  if (-not $registered) {
42
133
  throw "Scheduled task '$TaskName' did NOT register (Get-ScheduledTask found nothing)."
43
134
  }
44
- Write-Host "Registered scheduled task '$TaskName' (runs $Mode at logon, restarts on failure)."
135
+
136
+ # And verify the HEADLESS SHAPE landed, not merely that a task exists. A start
137
+ # path that did not persist is the original defect wearing a new name, and it
138
+ # would be invisible until the next unattended reboot. "Headless" here means two
139
+ # independent things, and BOTH are required: a trigger that fires without anyone
140
+ # logging on (boot or repetition), AND a principal that does not need an
141
+ # interactive session (anything but InteractiveToken).
142
+ $definition = Export-ScheduledTask -TaskName $TaskName
143
+ $logonType = if ($definition -match "<LogonType>([^<]+)</LogonType>") { $Matches[1] } else { "unknown" }
144
+ $hasLogonFreeTrigger = ($definition -match "<BootTrigger>") -or ($definition -match "<Repetition>")
145
+ if (-not $hasLogonFreeTrigger) {
146
+ # Only the last rung of the ladder can get here.
147
+ Write-Warning "Scheduled task '$TaskName' registered with a LOGON-ONLY trigger. Sensing will not resume after an unattended reboot until someone signs in."
148
+ }
149
+ if ($logonType -eq "InteractiveToken") {
150
+ Write-Warning "Scheduled task '$TaskName' registered with an INTERACTIVE principal. Sensing will only run while $UserId is logged on."
151
+ }
152
+ Write-Host "Registered scheduled task '$TaskName' (runs $Mode, shape '$($Landed.Label)', logon type $logonType, restarts on failure)."
153
+
154
+ # HEADLESS COSTS ONE PRIVILEGE, and that is a Windows rule rather than a choice.
155
+ # Measured on windows-latest (run 31836760844): a standard user registering for
156
+ # ITSELF is refused S4U with "Access is denied". S4U performs a BATCH logon, and
157
+ # Task Scheduler only grants the account SeBatchLogonRight when the CALLER is an
158
+ # administrator; boot triggers are Administrators-only for the same reason. So an
159
+ # unelevated install lands on 'Interactive + repeat' and is NOT headless.
160
+ #
161
+ # This script does NOT prompt for elevation -- it runs from install-update.ps1 and
162
+ # from the connector's own repair path, where a UAC dialog would be wrong or
163
+ # impossible. ensure-sensing.ps1 owns that prompt, at wizard close, once.
164
+ # What we DO here is use elevation when we already have it: repair the model
165
+ # server in the same elevated pass, so one prompt fixes both. The two must be
166
+ # headless TOGETHER -- auto-sense waits on 127.0.0.1:9379, so a headless
167
+ # connector paired with a logon-only model server is a connector that boots and
168
+ # senses nothing.
169
+ function Test-IsElevated {
170
+ $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
171
+ (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole(
172
+ [System.Security.Principal.WindowsBuiltInRole]::Administrator)
173
+ }
174
+
175
+ if (Test-IsElevated) {
176
+ $litertTask = Get-ScheduledTask -TaskName "TrillboardsLiteRtLm" -ErrorAction SilentlyContinue
177
+ if ($litertTask) {
178
+ $litertXml = Export-ScheduledTask -TaskName "TrillboardsLiteRtLm"
179
+ $litertHeadless = (($litertXml -match "<BootTrigger>") -or ($litertXml -match "<Repetition>")) -and
180
+ ($litertXml -notmatch "<LogonType>InteractiveToken</LogonType>")
181
+ if (-not $litertHeadless) {
182
+ # Recover the interpreter path from the task we are replacing rather than
183
+ # re-deriving it: setup-litertlm.ps1 resolved it under the USER profile,
184
+ # and an elevated re-derivation could resolve somewhere else entirely.
185
+ $litertExe = ($litertTask.Actions | Select-Object -First 1).Execute
186
+ $litertRegister = Join-Path $InstallDir "bin\register-litertlm-task.ps1"
187
+ if ($litertExe -and (Test-Path $litertRegister)) {
188
+ Write-Host "Repairing 'TrillboardsLiteRtLm' in the same elevated pass."
189
+ try { & $litertRegister -LitertLmPath $litertExe | Out-Null }
190
+ catch { Write-Warning "Could not repair the model-server task: $($_.Exception.Message)" }
191
+ }
192
+ }
193
+ }
194
+ }
@@ -9,6 +9,15 @@ param(
9
9
  [string]$ModelId = "gemma-4-E2B-it.litertlm",
10
10
  [string]$HfRepo = "litert-community/gemma-4-E2B-it-litert-lm",
11
11
  [string]$LitertPython = $(if ($env:LITERT_LM_PYTHON) { $env:LITERT_LM_PYTHON } else { "3.12" }),
12
+ # PINNED litert-lm - see deploy/macos/scripts/setup-litertlm.sh for why this is
13
+ # a correctness pin and not hygiene: the GPU delegate is selected by posting a
14
+ # "<model-id>,gpu" model id, upstream calls that syntax deprecated, and 0.14.0
15
+ # removed the parse outright. An unpinned --upgrade lets a customer's per-frame
16
+ # cost move 1.7-6.6x on a reinstall we did not ship. Kept in lockstep with the
17
+ # macOS script, LiteRtVlmProvider's LITERT_LM_PINNED_VERSION and
18
+ # .github/contracts/litert-lm-tested-versions.json by the litert-lm-version-pin
19
+ # guard.
20
+ [string]$LitertLmVersion = $(if ($env:LITERT_LM_VERSION) { $env:LITERT_LM_VERSION } else { "0.16.0" }),
12
21
  [string]$ModelVersion = $(if ($env:GEMMA_VLM_VERSION) { $env:GEMMA_VLM_VERSION } else { "1.0.0" }),
13
22
  [string]$BundledModelPath = "",
14
23
  [string]$BundledModelSha256 = "181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c",
@@ -109,19 +118,19 @@ function Test-ServeHealthy {
109
118
 
110
119
  # WHICH DELEGATE WILL THIS HOST RUN?
111
120
  #
112
- # litert-lm chooses its accelerator by MODEL ID, not by a flag: `serve`
113
- # advertises its GPU delegate as a SEPARATE id carrying a ",gpu" suffix, and the
114
- # connector prefers that variant when it is present (LiteRtVlmProvider,
115
- # accelerator 'auto' - the default).
121
+ # litert-lm chooses its accelerator by MODEL ID, not by a flag: the GPU delegate
122
+ # is selected by posting the imported id with a ",gpu" suffix, and the connector
123
+ # ATTEMPTS that id (LiteRtVlmProvider, accelerator 'auto' - the default).
116
124
  #
117
- # Test-ServeHealthy substring-matches the BARE id, so it passes identically
118
- # whether or not the GPU variant exists, and no Windows script has ever recorded
119
- # which ids this host actually advertises. That gap is why a 32-camera field
120
- # test on connector 0.2.13 could report a CPU-speed result (~2.4 analyses/min)
121
- # with no way to tell a CPU-only litert-lm build from a GPU-capable one that
122
- # failed at runtime. The Windows litert-lm runtime is the stock PyPI wheel
123
- # installed by `uv tool install` (unpinned), so its delegate list is not a fact
124
- # this repo controls - it has to be OBSERVED, per host.
125
+ # THE MODEL LIST IS NOT THE ANSWER. Measured 2026-08-14 against real serves:
126
+ # litert-lm 0.13.1 lists both ids; 0.14.0 lists one and rejects the ",gpu" POST
127
+ # with 404; 0.16.0 lists one and SERVES the ",gpu" POST on the GPU. So a missing
128
+ # entry means nothing about this host on its own, and the line below used to
129
+ # state flatly that such a machine ran on CPU - which is how a 32-camera field
130
+ # test on connector 0.2.13 reported a CPU-speed result (~2.4 analyses/min) and
131
+ # read it as the product's speed. Log the list, name the version, and let the
132
+ # connector's attempt settle the delegate. installerLocalAiFlow.test.ts asserts
133
+ # that sentence stays gone rather than merely de-prioritised.
125
134
  #
126
135
  # Never throws: a diagnostic must not be able to fail an install.
127
136
  function Get-AdvertisedModelIds {
@@ -145,9 +154,13 @@ function Write-DelegateDiagnostic {
145
154
  Log ("delegate: /v1/models advertises " + $ids.Count + " id(s): " + ($ids -join ", "))
146
155
  $gpuId = "$ModelId,gpu"
147
156
  if ($ids -contains $gpuId) {
148
- Log "delegate: GPU variant '$gpuId' IS advertised; the connector will run the GPU delegate"
157
+ Log "delegate: GPU variant '$gpuId' is listed; the connector attempts it on its first frame"
149
158
  } else {
150
- Log "delegate: no ',gpu' variant advertised; this host runs the CPU delegate (measured 2.8-6.6x slower per frame)"
159
+ # NOT a statement about this host. litert-lm 0.15+ omits this entry while
160
+ # still serving the id (measured on 0.16.0), so the only thing that settles
161
+ # it is the connector's own attempt, reported on its [sensing] line.
162
+ Log ("delegate: '$gpuId' is not listed by litert-lm $LitertLmVersion, which does not mean it is unavailable - " +
163
+ "0.15+ omits the entry and still serves the id. The connector attempts it on its first frame and logs the outcome.")
151
164
  }
152
165
  }
153
166
 
@@ -232,11 +245,48 @@ if (-not [Environment]::Is64BitOperatingSystem) {
232
245
  exit 1
233
246
  }
234
247
 
235
- if (Test-ServeHealthy) {
248
+ $litertlmCmd = Get-Command litert-lm -ErrorAction SilentlyContinue
249
+ $litertlmPath = if ($litertlmCmd) { $litertlmCmd.Source } else { Join-Path $LocalBin "litert-lm.exe" }
250
+ function Test-LitertLmCli {
251
+ if (-not (Test-Path -LiteralPath $litertlmPath)) { return $false }
252
+ try {
253
+ & $litertlmPath list *> $null
254
+ return ($LASTEXITCODE -eq 0)
255
+ } catch {
256
+ return $false
257
+ }
258
+ }
259
+
260
+ function Get-LitertLmVersion {
261
+ if (-not (Test-Path -LiteralPath $litertlmPath)) { return "" }
262
+ try {
263
+ $out = (& $litertlmPath --version 2>$null | Out-String).Trim()
264
+ if (-not $out) { return "" }
265
+ return ($out -split '\s+')[-1]
266
+ } catch {
267
+ return ""
268
+ }
269
+ }
270
+
271
+ # A HEALTHY SERVE IS NOT ENOUGH TO SKIP PROVISIONING.
272
+ #
273
+ # `Test-ServeHealthy` substring-matches the bare model id, and every litert-lm
274
+ # version answers that identically - including the ones that reject the `,gpu`
275
+ # model id the GPU delegate is selected with. So this fast path, which exists to
276
+ # make a repeat run cheap, was also the path that let an already-installed
277
+ # 0.14.0 declare itself ready forever: the hosts that most need the pinned
278
+ # runtime are exactly the ones with a serve already answering. The version is
279
+ # therefore part of the short-circuit condition, not a check further down that a
280
+ # healthy serve never reaches.
281
+ $installedLitertLmVersion = Get-LitertLmVersion
282
+ if ((Test-ServeHealthy) -and ($installedLitertLmVersion -eq $LitertLmVersion)) {
236
283
  Complete-Setup
237
284
  Stop-SetupLock
238
285
  exit 0
239
286
  }
287
+ if ((Test-ServeHealthy) -and ($installedLitertLmVersion -ne $LitertLmVersion)) {
288
+ Log "serve is healthy but litert-lm is $(if ($installedLitertLmVersion) { $installedLitertLmVersion } else { 'unknown' }) not pinned $LitertLmVersion; reinstalling and restarting the serve"
289
+ }
240
290
 
241
291
  $env:Path = "$LocalBin;$env:Path"
242
292
  if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
@@ -253,24 +303,20 @@ if (-not $uvCmd) {
253
303
  exit 1
254
304
  }
255
305
 
256
- $litertlmCmd = Get-Command litert-lm -ErrorAction SilentlyContinue
257
- $litertlmPath = if ($litertlmCmd) { $litertlmCmd.Source } else { Join-Path $LocalBin "litert-lm.exe" }
258
- function Test-LitertLmCli {
259
- if (-not (Test-Path -LiteralPath $litertlmPath)) { return $false }
260
- try {
261
- & $litertlmPath list *> $null
262
- return ($LASTEXITCODE -eq 0)
263
- } catch {
264
- return $false
265
- }
266
- }
267
-
268
- if (Test-LitertLmCli) {
269
- Log "litert-lm CLI is healthy; keeping current install"
306
+ # The version is part of "healthy". A list-only check kept each machine on
307
+ # whatever it first installed, which is exactly how the accelerator behaviour
308
+ # came to differ between our boxes and every host provisioned after 2026-07-08.
309
+ # Re-read it: uv may have appeared on PATH since the first read above.
310
+ $installedLitertLmVersion = Get-LitertLmVersion
311
+ if ((Test-LitertLmCli) -and ($installedLitertLmVersion -eq $LitertLmVersion)) {
312
+ Log "litert-lm CLI is healthy at pinned $LitertLmVersion; keeping current install"
270
313
  } else {
271
- Log "installing or repairing litert-lm with Python $LitertPython"
314
+ if ($installedLitertLmVersion -and ($installedLitertLmVersion -ne $LitertLmVersion)) {
315
+ Log "litert-lm $installedLitertLmVersion differs from pinned $LitertLmVersion; reinstalling"
316
+ }
317
+ Log "installing or repairing litert-lm==$LitertLmVersion with Python $LitertPython"
272
318
  Write-SetupProgress -Stage "local_ai" -Title "Installing local AI" -Message "Installing the local AI engine. Cloud inference remains off." -Phase "installing_runtime" -Percent 10
273
- & $uvCmd.Source tool install --python $LitertPython --managed-python --upgrade --force litert-lm *>> $LogPath
319
+ & $uvCmd.Source tool install --python $LitertPython --managed-python --force "litert-lm==$LitertLmVersion" *>> $LogPath
274
320
  $litertlmCmd = Get-Command litert-lm -ErrorAction SilentlyContinue
275
321
  $litertlmPath = if ($litertlmCmd) { $litertlmCmd.Source } else { Join-Path $LocalBin "litert-lm.exe" }
276
322
  if (-not (Test-LitertLmCli)) {
@@ -279,7 +325,7 @@ if (Test-LitertLmCli) {
279
325
  Stop-SetupLock
280
326
  exit 1
281
327
  }
282
- Log "litert-lm CLI repaired successfully"
328
+ Log "litert-lm CLI repaired successfully at $(Get-LitertLmVersion)"
283
329
  }
284
330
 
285
331
  $bundledModelVerified = $false
@@ -15,7 +15,7 @@ $rootPrefix = $root + '\'
15
15
  # DISABLE BEFORE STOP. register-task.ps1 registers the task with
16
16
  # `-RestartCount 99 -RestartInterval 1 minute`, so Stop-ScheduledTask ends the
17
17
  # instance as a FAILURE and Task Scheduler relaunches node.exe about a minute
18
- # later — right inside the installer's bin\ + lib\ move. That is the "reinstall
18
+ # later - right inside the installer's bin\ + lib\ move. That is the "reinstall
19
19
  # hit a locked node.exe" the field test saw. Disabling suppresses the restart
20
20
  # AND the AtLogOn trigger for the whole swap window; install-update.ps1 owns
21
21
  # re-enabling it on every exit path, and the uninstaller deletes the task.
@@ -25,7 +25,7 @@ Start-Sleep -Milliseconds 750
25
25
 
26
26
  function Get-TrillboardsEdgeProcesses {
27
27
  # The process that actually holds bin\node.exe open is node.exe itself, and
28
- # its command line names NONE of these scripts — it names the cli.js entry.
28
+ # its command line names NONE of these scripts - it names the cli.js entry.
29
29
  # Win32_Process.ExecutablePath is empty across a 32/64-bit boundary, and then
30
30
  # the marker list is the only matcher left, so it has to cover the runtime.
31
31
  $markers = @(