ever-works-node 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1285 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pure decision logic for install-service.ps1.
4
+
5
+ .DESCRIPTION
6
+ Everything install-service.ps1 has to get RIGHT but cannot exercise
7
+ without changing the machine lives here: account normalisation, the
8
+ service configuration plan, the scheduled-task registration plan, the
9
+ ACL convergence plan, and the preflight verdicts.
10
+
11
+ The rules for this file:
12
+
13
+ * No I/O. Nothing here reads the registry, touches a file, queries
14
+ an ACL, or starts a process. The installer gathers those facts and
15
+ passes them in; these functions only DECIDE. That is what lets
16
+ test-install-service-logic.ps1 cover the branches that would
17
+ otherwise need a real service on a real fleet workstation.
18
+
19
+ * No side effects at load. Dot-sourcing this file defines functions
20
+ and nothing else, so the installer and the test harness can both
21
+ pull it in with
22
+ `. (Join-Path $PSScriptRoot 'install-service.logic.ps1')`.
23
+
24
+ * No secrets. The plans returned from here never carry a password. A
25
+ step that needs one is FLAGGED (RequiresPassword) and the installer
26
+ supplies the secret at the moment of the call, through unmanaged
27
+ memory. A plan is therefore always safe to print, to log, and to
28
+ assert on.
29
+
30
+ * ASCII only. Windows PowerShell 5.1 decodes a BOM-less script as the
31
+ system ANSI codepage, so a UTF-8 em dash arrives as three CP1252
32
+ characters whose last one is a curly double quote - which
33
+ PowerShell accepts as a string terminator. A single em dash inside
34
+ a double-quoted string is therefore a PARSE ERROR on the very hosts
35
+ this installer targets. The two scripts CI already runs under 5.1
36
+ (native/windows-job-launcher/*.ps1) are pure ASCII for this reason;
37
+ windows-service-contract.internal.spec.ts pins it.
38
+
39
+ Dot-sourced rather than imported as a module on purpose: the six fleet
40
+ workstations run Windows PowerShell 5.1 with no pwsh, and a .psm1 would
41
+ drag in module-path and manifest questions on machines an operator only
42
+ ever touches to run this one installer. build.js copies the whole
43
+ `packaging` directory into the published package
44
+ (files: ['cli.js', 'packaging', ...]), so this file ships beside
45
+ install-service.ps1 wherever that script is run from.
46
+ #>
47
+
48
+ # --- Command-line quoting -------------------------------------------------
49
+
50
+ # Quote ONE argument the way CommandLineToArgvW / the MSVC C runtime will
51
+ # read it back: wrap in double quotes when needed, escape embedded double
52
+ # quotes, and double the backslashes that sit in front of a quote (or at
53
+ # the very end, where they would otherwise escape the closing quote).
54
+ function ConvertTo-CommandLineArgument {
55
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Value)
56
+ if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') {
57
+ return $Value
58
+ }
59
+ $escaped = [regex]::Replace($Value, '(\\*)"', '$1$1\"')
60
+ $escaped = [regex]::Replace($escaped, '(\\+)$', '$1$1')
61
+ return "`"$escaped`""
62
+ }
63
+
64
+ # Join a whole argument vector into the single command-line string that
65
+ # CreateProcess actually takes. Windows PowerShell 5.1 would otherwise
66
+ # re-quote each spaced element WITHOUT escaping the quotes inside it, so a
67
+ # pinned path such as "C:\Program Files\nodejs\claude.cmd" arrives split
68
+ # into two arguments.
69
+ function ConvertTo-CommandLine {
70
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]] $Arguments)
71
+ return (($Arguments | ForEach-Object { ConvertTo-CommandLineArgument $_ }) -join ' ')
72
+ }
73
+
74
+ # One rendering of "program plus arguments", used by every dry-run line and
75
+ # every failure message, so the two can never describe different calls.
76
+ function Format-NativeCommandLine {
77
+ param(
78
+ [Parameter(Mandatory = $true)][string] $FilePath,
79
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]] $Arguments
80
+ )
81
+ return "$(ConvertTo-CommandLineArgument $FilePath) $(ConvertTo-CommandLine $Arguments)"
82
+ }
83
+
84
+ # --- Service account identity --------------------------------------------
85
+
86
+ # The accounts Windows lets a service log on as WITHOUT a password. The SCM's
87
+ # lpServiceStartName and New-ScheduledTaskPrincipal's -UserId disagree about
88
+ # the spelling of every one of them, which is why each entry carries both.
89
+ function Get-BuiltInServiceAccountMap {
90
+ $system = [pscustomobject]@{ Service = 'LocalSystem'; Task = 'NT AUTHORITY\SYSTEM'; Label = 'LocalSystem' }
91
+ $local = [pscustomobject]@{ Service = 'NT AUTHORITY\LocalService'; Task = 'NT AUTHORITY\LOCAL SERVICE'; Label = 'LocalService' }
92
+ $network = [pscustomobject]@{ Service = 'NT AUTHORITY\NetworkService'; Task = 'NT AUTHORITY\NETWORK SERVICE'; Label = 'NetworkService' }
93
+ return @{
94
+ 'localsystem' = $system
95
+ 'system' = $system
96
+ 'nt authority\system' = $system
97
+ '.\localsystem' = $system
98
+ 'localservice' = $local
99
+ 'local service' = $local
100
+ 'nt authority\localservice' = $local
101
+ 'nt authority\local service' = $local
102
+ 'networkservice' = $network
103
+ 'network service' = $network
104
+ 'nt authority\networkservice' = $network
105
+ 'nt authority\network service' = $network
106
+ }
107
+ }
108
+
109
+ <#
110
+ .SYNOPSIS
111
+ Normalise an operator-supplied account into the two spellings Windows
112
+ needs, plus whether it takes a password.
113
+
114
+ .DESCRIPTION
115
+ Accepted forms, all of which real operators type:
116
+
117
+ (blank) the account running the installer
118
+ DOMAIN\User a domain account
119
+ MACHINE\User a local account named by this machine
120
+ .\User a local account, the SCM's own shorthand
121
+ User a bare name, read as local (.\User)
122
+ user@domain.tld a UPN
123
+ DOMAIN\name$ a group Managed Service Account - NO password
124
+ LocalSystem and the other built-in service accounts - NO password
125
+ NT SERVICE\name a virtual service account - NO password
126
+
127
+ MachineName / DefaultDomain / DefaultUser are injected rather than read
128
+ from $env so every branch is reachable from a test on any host.
129
+ #>
130
+ function Resolve-ServiceAccountIdentity {
131
+ param(
132
+ [AllowEmptyString()][AllowNull()][string] $Account,
133
+ [Parameter(Mandatory = $true)][string] $MachineName,
134
+ [Parameter(Mandatory = $true)][string] $DefaultDomain,
135
+ [Parameter(Mandatory = $true)][string] $DefaultUser
136
+ )
137
+
138
+ $raw = ''
139
+ if ($null -ne $Account) { $raw = $Account.Trim() }
140
+ if ($raw.Length -eq 0) {
141
+ $raw = "$DefaultDomain\$DefaultUser"
142
+ }
143
+
144
+ $builtIn = (Get-BuiltInServiceAccountMap)[$raw.ToLowerInvariant()]
145
+ if ($null -ne $builtIn) {
146
+ return [pscustomobject]@{
147
+ Input = $Account
148
+ Kind = 'builtin'
149
+ Domain = 'NT AUTHORITY'
150
+ UserName = $builtIn.Label
151
+ SamAccountName = $builtIn.Task
152
+ ServiceLogonName = $builtIn.Service
153
+ TaskUserId = $builtIn.Task
154
+ RequiresPassword = $false
155
+ IsBuiltIn = $true
156
+ NeedsLogonRight = $false
157
+ Description = "built-in service account $($builtIn.Label)"
158
+ }
159
+ }
160
+
161
+ if ($raw -match '[/"]') {
162
+ throw "-ServiceAccount '$raw' is not a valid Windows account name (a forward slash or a double quote is never part of one). Use DOMAIN\User, .\User, MACHINE\User, a UPN, or a gMSA name ending in '$'."
163
+ }
164
+
165
+ # @() so the count is still 0 (not $null) when nothing matches, which
166
+ # Set-StrictMode -Version Latest in the test harness would trip on.
167
+ $separators = @($raw.ToCharArray() | Where-Object { $_ -eq '\' }).Count
168
+ if ($separators -gt 1) {
169
+ throw "-ServiceAccount '$raw' has more than one '\' separator; expected DOMAIN\User."
170
+ }
171
+ if ($separators -eq 1 -and $raw.Contains('@')) {
172
+ throw "-ServiceAccount '$raw' mixes the DOMAIN\User and user@domain forms; use one or the other."
173
+ }
174
+
175
+ if ($separators -eq 0 -and $raw.Contains('@')) {
176
+ $parts = $raw.Split('@')
177
+ if ($parts.Length -ne 2 -or $parts[0].Length -eq 0 -or $parts[1].Length -eq 0) {
178
+ throw "-ServiceAccount '$raw' is not a valid UPN; expected user@domain.tld."
179
+ }
180
+ return [pscustomobject]@{
181
+ Input = $Account
182
+ Kind = 'upn'
183
+ Domain = $parts[1]
184
+ UserName = $parts[0]
185
+ SamAccountName = $raw
186
+ ServiceLogonName = $raw
187
+ TaskUserId = $raw
188
+ RequiresPassword = $true
189
+ IsBuiltIn = $false
190
+ NeedsLogonRight = $true
191
+ Description = "domain account $raw (UPN)"
192
+ }
193
+ }
194
+
195
+ $domain = ''
196
+ $user = $raw
197
+ if ($separators -eq 1) {
198
+ $index = $raw.IndexOf('\')
199
+ $domain = $raw.Substring(0, $index)
200
+ $user = $raw.Substring($index + 1)
201
+ }
202
+ if ($user.Length -eq 0) {
203
+ throw "-ServiceAccount '$raw' names no user; expected DOMAIN\User or .\User."
204
+ }
205
+ if ($separators -eq 1 -and $domain.Length -eq 0) {
206
+ throw "-ServiceAccount '$raw' names no domain; use '.\$user' for a local account."
207
+ }
208
+
209
+ # NT SERVICE\<name> is a virtual account: the SCM derives its password,
210
+ # so passing one is an error rather than an omission.
211
+ if ($domain -ieq 'NT SERVICE') {
212
+ return [pscustomobject]@{
213
+ Input = $Account
214
+ Kind = 'virtual'
215
+ Domain = 'NT SERVICE'
216
+ UserName = $user
217
+ SamAccountName = "NT SERVICE\$user"
218
+ ServiceLogonName = "NT SERVICE\$user"
219
+ TaskUserId = "NT SERVICE\$user"
220
+ RequiresPassword = $false
221
+ IsBuiltIn = $true
222
+ NeedsLogonRight = $false
223
+ Description = "virtual service account NT SERVICE\$user"
224
+ }
225
+ }
226
+
227
+ $isLocal = ($domain.Length -eq 0) -or ($domain -eq '.') -or ($domain -ieq $MachineName)
228
+
229
+ # A gMSA is spelled with a trailing '$' and is DOMAIN-scoped by
230
+ # definition: the domain controller holds the password, which is the
231
+ # entire point of the account type. The SCM takes the name with an EMPTY
232
+ # password, and the account still needs SeServiceLogonRight like any
233
+ # other - so this is a password-less account that is NOT a built-in.
234
+ if ($user.EndsWith('$') -and -not $isLocal) {
235
+ return [pscustomobject]@{
236
+ Input = $Account
237
+ Kind = 'gmsa'
238
+ Domain = $domain
239
+ UserName = $user
240
+ SamAccountName = "$domain\$user"
241
+ ServiceLogonName = "$domain\$user"
242
+ TaskUserId = "$domain\$user"
243
+ RequiresPassword = $false
244
+ IsBuiltIn = $false
245
+ NeedsLogonRight = $true
246
+ Description = "managed service account $domain\$user"
247
+ }
248
+ }
249
+
250
+ if ($isLocal) {
251
+ return [pscustomobject]@{
252
+ Input = $Account
253
+ Kind = 'local'
254
+ Domain = $MachineName
255
+ UserName = $user
256
+ SamAccountName = "$MachineName\$user"
257
+ # The SCM documents '.\user' for a local account and resolves it
258
+ # against the machine itself; the task scheduler wants the
259
+ # machine spelled out and rejects the '.' shorthand.
260
+ ServiceLogonName = ".\$user"
261
+ TaskUserId = "$MachineName\$user"
262
+ RequiresPassword = $true
263
+ IsBuiltIn = $false
264
+ NeedsLogonRight = $true
265
+ Description = "local account $MachineName\$user"
266
+ }
267
+ }
268
+
269
+ return [pscustomobject]@{
270
+ Input = $Account
271
+ Kind = 'domain'
272
+ Domain = $domain
273
+ UserName = $user
274
+ SamAccountName = "$domain\$user"
275
+ ServiceLogonName = "$domain\$user"
276
+ TaskUserId = "$domain\$user"
277
+ RequiresPassword = $true
278
+ IsBuiltIn = $false
279
+ NeedsLogonRight = $true
280
+ Description = "domain account $domain\$user"
281
+ }
282
+ }
283
+
284
+ <#
285
+ .SYNOPSIS
286
+ True when Windows will demand a password for this account.
287
+
288
+ .DESCRIPTION
289
+ The branch that decides whether -ServicePassword is mandatory. A blank
290
+ account means "the installer's own account", which is always a real
291
+ user and therefore always needs one - so the DefaultDomain/DefaultUser
292
+ that Resolve-ServiceAccountIdentity would substitute cannot change the
293
+ answer, and this wrapper does not need them.
294
+ #>
295
+ function Test-ServiceAccountRequiresPassword {
296
+ param(
297
+ [AllowEmptyString()][AllowNull()][string] $Account,
298
+ [Parameter(Mandatory = $true)][string] $MachineName
299
+ )
300
+ $identity = Resolve-ServiceAccountIdentity `
301
+ -Account $Account `
302
+ -MachineName $MachineName `
303
+ -DefaultDomain $MachineName `
304
+ -DefaultUser 'installer'
305
+ return $identity.RequiresPassword
306
+ }
307
+
308
+ # --- Access verdicts (pure rules over facts the installer measured) -------
309
+
310
+ <#
311
+ .SYNOPSIS
312
+ The SIDs whose ACEs may legitimately stand in for this account.
313
+
314
+ .DESCRIPTION
315
+ A false 'granted' is far worse than an 'unknown': it hides exactly the
316
+ bug this installer exists to fix. So a well-known group is only
317
+ considered when the chosen account is genuinely in it.
318
+
319
+ * Everyone (S-1-1-0) and Authenticated Users (S-1-5-11) are in the
320
+ token of every account that authenticates, gMSAs and virtual
321
+ service accounts included.
322
+ * BUILTIN\Users (S-1-5-32-545) is a LOCAL group. Local users are in
323
+ it, and so are domain users (joining a domain puts Domain Users in
324
+ it). A gMSA and an NT SERVICE\<name> virtual account are NOT.
325
+
326
+ Administrators is deliberately absent: assuming it would turn "the
327
+ account happens to be an admin" into a silent pass for accounts that
328
+ are not.
329
+ #>
330
+ function Get-AccessProbeSidList {
331
+ param(
332
+ [Parameter(Mandatory = $true)][string] $Sid,
333
+ [Parameter(Mandatory = $true)][string] $IdentityKind
334
+ )
335
+ $sids = [Collections.Generic.List[string]]::new()
336
+ $sids.Add($Sid)
337
+ $sids.Add('S-1-1-0')
338
+ $sids.Add('S-1-5-11')
339
+ if (@('local', 'domain', 'upn') -contains $IdentityKind) {
340
+ $sids.Add('S-1-5-32-545')
341
+ }
342
+ return , $sids.ToArray()
343
+ }
344
+
345
+ <#
346
+ .SYNOPSIS
347
+ Three-valued access answer for one DACL and one account.
348
+
349
+ .DESCRIPTION
350
+ $Rules is the ACE list reduced to what the decision needs - SidValue,
351
+ Rights (the FileSystemRights bitmask as an int) and IsDeny - so this
352
+ stays pure and the whole table is reachable from a test.
353
+
354
+ Three rules, each of which has bitten this script:
355
+
356
+ * A Deny ACE that overlaps the wanted rights AT ALL denies the
357
+ request, even when it covers only some of them. Windows evaluates
358
+ access per bit; requiring a Deny ACE to be a superset (which an
359
+ earlier draft did) reported 'granted' for a path the account
360
+ cannot actually use.
361
+ * Allow ACEs are CUMULATIVE. Two ACEs granting Read and Write
362
+ together satisfy a Modify request even though neither does alone.
363
+ * Absence proves a denial only when inheritance has been stripped.
364
+ That is exactly the shape node-io.ts writes the config file in
365
+ (icacls /inheritance:r /grant:r <user>:(F)), which is what makes
366
+ the most important check here a precise one. Where the DACL still
367
+ inherits, the account may reach the file through a group this list
368
+ does not enumerate, so the answer is 'unknown' and the preflight
369
+ warns rather than blocking a correct install.
370
+ #>
371
+ function Get-AccessVerdictFromAcl {
372
+ param(
373
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]] $Rules,
374
+ [Parameter(Mandatory = $true)][bool] $IsProtected,
375
+ [Parameter(Mandatory = $true)][string[]] $ConsideredSids,
376
+ [Parameter(Mandatory = $true)][int] $WantedRights
377
+ )
378
+
379
+ $granted = 0
380
+ foreach ($rule in $Rules) {
381
+ if ($ConsideredSids -notcontains $rule.SidValue) { continue }
382
+ $overlap = ([int] $rule.Rights) -band $WantedRights
383
+ if ($overlap -eq 0) { continue }
384
+ if ($rule.IsDeny) { return 'denied' }
385
+ $granted = $granted -bor $overlap
386
+ }
387
+
388
+ if (($granted -band $WantedRights) -eq $WantedRights) { return 'granted' }
389
+ if ($IsProtected) { return 'denied' }
390
+ return 'unknown'
391
+ }
392
+
393
+ # Mirrors WINDOWS_LAUNCHABLE in model-cli-probe.ts so the preflight and the
394
+ # node's own probe can never disagree about what counts as launchable.
395
+ function Get-WindowsLaunchableExtension {
396
+ return , @('.cmd', '.exe', '.bat')
397
+ }
398
+
399
+ # The pin verdict, from facts the installer measured. A pin that does not
400
+ # resolve DISABLES that CLI rather than falling back to PATH, so every state
401
+ # below except 'unpinned' and 'ok' means the node would silently stop
402
+ # advertising the capability - or advertise it and fail every job.
403
+ function Get-ModelCliPinStateFromFacts {
404
+ param(
405
+ [AllowEmptyString()][AllowNull()][string] $PinnedPath,
406
+ [Parameter(Mandatory = $true)][bool] $Exists,
407
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Extension,
408
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $AccessVerdict
409
+ )
410
+ if ([string]::IsNullOrWhiteSpace($PinnedPath)) { return 'unpinned' }
411
+ if (-not $Exists) { return 'missing' }
412
+ if ((Get-WindowsLaunchableExtension) -notcontains $Extension.ToLowerInvariant()) { return 'not-launchable' }
413
+ if ($AccessVerdict -eq 'denied') { return 'unreadable' }
414
+ return 'ok'
415
+ }
416
+
417
+ function Test-PathUnder {
418
+ param(
419
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Path,
420
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Ancestor
421
+ )
422
+ if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Ancestor)) { return $false }
423
+ $normalisedPath = $Path.TrimEnd('\') + '\'
424
+ $normalisedAncestor = $Ancestor.TrimEnd('\') + '\'
425
+ return $normalisedPath.StartsWith($normalisedAncestor, [StringComparison]::OrdinalIgnoreCase)
426
+ }
427
+
428
+ # --- Service configuration plan -------------------------------------------
429
+
430
+ <#
431
+ .SYNOPSIS
432
+ The ordered steps that register and configure the service.
433
+
434
+ .DESCRIPTION
435
+ `nssm install` calls CreateService with a NULL lpServiceStartName,
436
+ which the Win32 API defines as LocalSystem. LocalSystem has its own
437
+ profile, so the node looks for its config under
438
+ C:\Windows\system32\config\systemprofile\AppData\Roaming, reports "not
439
+ enrolled" (exit 3), and is restarted every 10 seconds forever. Hence
440
+ the 'identity' step.
441
+
442
+ Placement matters three times over:
443
+
444
+ * INSIDE the convergent block, not in the fresh-install branch - a
445
+ re-run to change -ClaudePath must re-apply the identity too, or the
446
+ service silently keeps whatever the first install used.
447
+ * BEFORE the service is started, or the first start runs under the
448
+ old identity.
449
+ * BEFORE `Start SERVICE_AUTO_START`, which is therefore the LAST
450
+ step. If anything fails in between, the half-configured service
451
+ cannot come back as LocalSystem at the next boot even if the
452
+ installer's own rollback also fails.
453
+
454
+ The identity step is a plan entry rather than an `nssm set ObjectName`
455
+ call because NSSM takes the password as a command-line ARGUMENT, and a
456
+ child process's command line is recorded by Security event 4688 where
457
+ command-line auditing is enabled and uploaded to the cloud by any EDR
458
+ agent that captures ProcessCommandLine. The installer instead calls
459
+ ChangeServiceConfigW with the password in unmanaged memory and grants
460
+ SeServiceLogonRight itself - the one thing NSSM was doing for us that
461
+ the SCM does not do on its own. Nothing in this plan ever carries the
462
+ secret; the step is FLAGGED RequiresPassword and the plan stays safe to
463
+ print and to assert on.
464
+ #>
465
+ <#
466
+ .SYNOPSIS
467
+ How big node.log / node.err.log may grow before they are rotated, and
468
+ how many rotated generations the scheduled-task branch keeps.
469
+
470
+ .DESCRIPTION
471
+ Self-build slice AP. Both mechanisms used to append to the same two
472
+ files forever: the NSSM service had no AppRotate* settings at all, and
473
+ the scheduled task redirected through cmd.exe with nothing ever
474
+ trimming the target. On a node that runs for months that is the log
475
+ eating the very disk the workspace floor guards.
476
+
477
+ One policy, read by both branches, so the two cannot drift: 10 MB per
478
+ file. The service rotates with NSSM's own AppRotateFiles /
479
+ AppRotateOnline / AppRotateBytes (online, while running); NSSM never
480
+ deletes a rotated file, so the service branch also registers a daily
481
+ prune task that keeps the newest Keep generations (New-LogPruneScript).
482
+ The scheduled task rotates when it STARTS (boot, and every
483
+ restart-on-failure) and keeps the newest Keep generations - see
484
+ New-ScheduledTaskRotationActionSpec for why it cannot rotate while
485
+ running.
486
+ #>
487
+ function Get-NodeLogRotationPolicy {
488
+ return [pscustomobject]@{
489
+ Bytes = 10485760
490
+ Keep = 5
491
+ }
492
+ }
493
+
494
+ function New-ServiceConfigurationPlan {
495
+ param(
496
+ [Parameter(Mandatory = $true)][string] $Name,
497
+ [Parameter(Mandatory = $true)][string] $NodeExe,
498
+ [Parameter(Mandatory = $true)][string] $ArgumentLine,
499
+ [Parameter(Mandatory = $true)][string] $StateDir,
500
+ [Parameter(Mandatory = $true)][string] $ServiceLogonName,
501
+ [switch] $ServiceExists,
502
+ [switch] $WithPassword
503
+ )
504
+
505
+ $steps = [Collections.Generic.List[object]]::new()
506
+ $addNssm = {
507
+ param([string[]] $Arguments, [string] $Description)
508
+ $steps.Add([pscustomobject]@{
509
+ Kind = 'nssm'
510
+ Arguments = $Arguments
511
+ Account = ''
512
+ Description = $Description
513
+ RequiresPassword = $false
514
+ })
515
+ }
516
+
517
+ if (-not $ServiceExists) {
518
+ & $addNssm @('install', $Name, $NodeExe) 'register the service'
519
+ }
520
+
521
+ & $addNssm @('set', $Name, 'Application', $NodeExe) 'point the service at node.exe'
522
+ & $addNssm @('set', $Name, 'AppParameters', $ArgumentLine) 'apply the current cli.js flags'
523
+ & $addNssm @('set', $Name, 'AppDirectory', $StateDir) 'set the working directory'
524
+
525
+ $steps.Add([pscustomobject]@{
526
+ Kind = 'identity'
527
+ Arguments = @($Name, $ServiceLogonName)
528
+ Account = $ServiceLogonName
529
+ Description = "run as $ServiceLogonName"
530
+ RequiresPassword = $WithPassword.IsPresent
531
+ })
532
+
533
+ & $addNssm @('set', $Name, 'AppStdout', (Join-Path $StateDir 'node.log')) 'redirect stdout'
534
+ & $addNssm @('set', $Name, 'AppStderr', (Join-Path $StateDir 'node.err.log')) 'redirect stderr'
535
+ # Self-build slice AP: rotate both logs - when the service starts, and
536
+ # ONLINE while it runs - past the shared size policy. Re-applied on every
537
+ # run like every other setting, so re-running the installer converges a
538
+ # service installed before rotation existed.
539
+ $rotation = Get-NodeLogRotationPolicy
540
+ & $addNssm @('set', $Name, 'AppRotateFiles', '1') 'rotate the logs when the service starts'
541
+ & $addNssm @('set', $Name, 'AppRotateOnline', '1') 'rotate the logs while the service runs'
542
+ & $addNssm @('set', $Name, 'AppRotateBytes', [string]$rotation.Bytes) "rotate a log once it passes $($rotation.Bytes) bytes"
543
+ & $addNssm @('set', $Name, 'AppStopMethodConsole', '900000') 'give the drain 15 minutes'
544
+ & $addNssm @('set', $Name, 'AppExit', 'Default', 'Restart') 'restart on exit'
545
+ & $addNssm @('set', $Name, 'AppRestartDelay', '10000') 'back off 10s between restarts'
546
+ & $addNssm @('set', $Name, 'Start', 'SERVICE_AUTO_START') 'start at boot'
547
+
548
+ return , $steps.ToArray()
549
+ }
550
+
551
+ # Render one plan step as the operation it will actually perform. A step
552
+ # that takes a password renders the PLACEHOLDER, never the value - the
553
+ # password is never IN the plan, and this is the only rendering path in the
554
+ # installer, so no caller (dry-run line, progress message, or exception) can
555
+ # print one by accident.
556
+ function Format-ServicePlanStep {
557
+ param(
558
+ [Parameter(Mandatory = $true)][string] $NssmPath,
559
+ [Parameter(Mandatory = $true)] $Step
560
+ )
561
+ if ($Step.Kind -eq 'identity') {
562
+ $rendered = "Set-ServiceLogonIdentity -Name $(ConvertTo-CommandLineArgument $Step.Arguments[0]) -Account $(ConvertTo-CommandLineArgument $Step.Account)"
563
+ if ($Step.RequiresPassword) {
564
+ $rendered += ' -Password <redacted -ServicePassword>'
565
+ }
566
+ return $rendered
567
+ }
568
+ return (Format-NativeCommandLine -FilePath $NssmPath -Arguments $Step.Arguments)
569
+ }
570
+
571
+ # --- State directory and log ACLs -----------------------------------------
572
+
573
+ <#
574
+ .SYNOPSIS
575
+ The icacls calls that let the chosen account write its own logs.
576
+
577
+ .DESCRIPTION
578
+ %ProgramData%\ever-works-node is the service's AppDirectory and the
579
+ parent of node.log / node.err.log on BOTH mechanisms, and nothing else
580
+ grants the service account anything there. Two measured facts make that
581
+ a real failure rather than a theoretical one:
582
+
583
+ * Files created inside a %ProgramData% subfolder by a privileged
584
+ identity inherit only BUILTIN\Users: ReadAndExecute. The
585
+ `Users: Write` ACE on C:\ProgramData is ContainerInherit ONLY, so
586
+ it reaches subfolders and never files.
587
+ * `cmd.exe /s /c "... >> <unwritable> 2>> <unwritable>"` exits 1,
588
+ prints "Access is denied." to the inherited stderr, and never runs
589
+ the command at all. Under the Task Scheduler that stderr goes
590
+ nowhere, so the result is a task with last-run 0x1, a node that
591
+ never started, and both log files empty.
592
+
593
+ The case that actually happens is the one this change creates: a
594
+ machine installed by the OLD script wrote node.log as LocalSystem, the
595
+ operator re-runs the fixed installer with -ServiceAccount, and the new
596
+ account inherits read-only access to the existing file. So the log
597
+ files are converged as well as the directory, and inheritance flags are
598
+ spelled per target - (OI)(CI) is meaningless on a file.
599
+ #>
600
+ function New-StateDirectoryAclPlan {
601
+ param(
602
+ [Parameter(Mandatory = $true)][string] $StateDir,
603
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]] $ExistingLogPaths,
604
+ [Parameter(Mandatory = $true)][string] $Account
605
+ )
606
+
607
+ $steps = [Collections.Generic.List[object]]::new()
608
+ $steps.Add([pscustomobject]@{
609
+ Arguments = @($StateDir, '/grant', "$($Account):(OI)(CI)(M)")
610
+ Description = "let $Account write the state directory and anything created in it"
611
+ })
612
+ foreach ($log in $ExistingLogPaths) {
613
+ $steps.Add([pscustomobject]@{
614
+ Arguments = @($log, '/grant', "$($Account):(M)")
615
+ Description = "let $Account append to a log file written under a previous identity"
616
+ })
617
+ }
618
+ return , $steps.ToArray()
619
+ }
620
+
621
+ # --- Scheduled-task plan --------------------------------------------------
622
+
623
+ <#
624
+ .SYNOPSIS
625
+ How to register the fallback scheduled task for this identity.
626
+
627
+ .DESCRIPTION
628
+ Register-ScheduledTask has two DISJOINT parameter sets (verified with
629
+ (Get-Command Register-ScheduledTask).ParameterSets on Windows 10 22H2,
630
+ PowerShell 5.1.19041): the [Principal] set takes -Principal and has no
631
+ -Password at all, and the [User] set takes -User / -Password /
632
+ -RunLevel and has no -Principal. So a password forces the [User] set
633
+ and the principal object is not built at all.
634
+
635
+ LogonType is the second, independent identity bug in this branch. The
636
+ original script pinned S4U, which mints a token WITHOUT the account's
637
+ password: it carries no network credentials and never unlocks the
638
+ account's DPAPI master key, so Windows Credential Manager reads fail
639
+ exactly as they do under LocalSystem. That is the git credential helper
640
+ today, and the node's own credential as soon as the optional
641
+ @napi-rs/keyring dependency is installed. Supplying -ServicePassword is
642
+ what turns it into a real password (batch) logon.
643
+
644
+ The password-less accounts do NOT want S4U either, and for different
645
+ reasons: a gMSA has a password, held by the domain controller, so its
646
+ supported shape is -LogonType Password with no -Password value; the
647
+ built-ins and NT SERVICE\<name> virtual accounts want ServiceAccount.
648
+ S4U is only ever right for a real user whose password we were not
649
+ given.
650
+ #>
651
+ function New-ScheduledTaskRegistrationPlan {
652
+ param(
653
+ [Parameter(Mandatory = $true)] $Identity,
654
+ [switch] $WithPassword
655
+ )
656
+
657
+ if ($WithPassword) {
658
+ return [pscustomobject]@{
659
+ ParameterSet = 'User'
660
+ LogonType = 'Password'
661
+ PrincipalParameters = $null
662
+ RegisterParameters = [ordered]@{ User = $Identity.TaskUserId; RunLevel = 'Highest' }
663
+ }
664
+ }
665
+
666
+ $logonType = 'S4U'
667
+ if ($Identity.IsBuiltIn) {
668
+ $logonType = 'ServiceAccount'
669
+ }
670
+ elseif ($Identity.Kind -eq 'gmsa') {
671
+ # A gMSA HAS a password; the domain controller holds it. The task
672
+ # scheduler's shape for that is a Password logon with no value.
673
+ $logonType = 'Password'
674
+ }
675
+
676
+ # The operator-facing warning about what S4U costs lives in
677
+ # Get-ServiceInstallPreflightFinding ('s4u-logon') so the preflight
678
+ # report stays the single place an operator reads for diagnosis.
679
+ return [pscustomobject]@{
680
+ ParameterSet = 'Principal'
681
+ LogonType = $logonType
682
+ PrincipalParameters = [ordered]@{ UserId = $Identity.TaskUserId; LogonType = $logonType; RunLevel = 'Highest' }
683
+ RegisterParameters = [ordered]@{}
684
+ }
685
+ }
686
+
687
+ <#
688
+ .SYNOPSIS
689
+ The task action, with the stdout/stderr redirection the service branch
690
+ already has.
691
+
692
+ .DESCRIPTION
693
+ New-ScheduledTaskAction has no redirection facility of its own, so the
694
+ only way to land the same two files NSSM's AppStdout / AppStderr
695
+ produce is to run the node under cmd.exe. The script header refuses
696
+ cmd.exe for the SERVICE because NSSM stops a service by sending a
697
+ console control event and cmd.exe swallows it ("Terminate batch job
698
+ (Y/N)?"), which kills the drain. That objection does not apply here:
699
+ Stop-ScheduledTask terminates the task's processes outright and
700
+ delivers no console event, so this branch never drained in the first
701
+ place. Trading a console signal that was never sent for logs that were
702
+ never written is the right way round - and it is one more reason this
703
+ branch stays the FALLBACK rather than the recommended shape.
704
+
705
+ `/s` is load-bearing: it makes cmd.exe strip exactly the first and last
706
+ quote and take everything between them verbatim, which is the only
707
+ deterministic behaviour when the inner command line is itself quoted.
708
+ #>
709
+ function New-ScheduledTaskActionSpec {
710
+ param(
711
+ [Parameter(Mandatory = $true)][string] $ComSpec,
712
+ [Parameter(Mandatory = $true)][string] $NodeExe,
713
+ [Parameter(Mandatory = $true)][string] $ArgumentLine,
714
+ [Parameter(Mandatory = $true)][string] $StdoutPath,
715
+ [Parameter(Mandatory = $true)][string] $StderrPath
716
+ )
717
+ $inner = "$(ConvertTo-CommandLineArgument $NodeExe) $ArgumentLine"
718
+ $redirect = ">> $(ConvertTo-CommandLineArgument $StdoutPath) 2>> $(ConvertTo-CommandLineArgument $StderrPath)"
719
+ return [pscustomobject]@{
720
+ Execute = $ComSpec
721
+ Argument = "/s /c `"$inner $redirect`""
722
+ }
723
+ }
724
+
725
+ <#
726
+ .SYNOPSIS
727
+ The PowerShell that rotates node.log / node.err.log, as text.
728
+
729
+ .DESCRIPTION
730
+ Self-build slice AP. A log at or past RotateBytes is renamed to
731
+ <name>.<yyyyMMddTHHmmss> - a timestamp, like NSSM's own rotated names -
732
+ and only the newest Keep rotated generations of that log are kept. A missing log, a locked log or any
733
+ other failure is swallowed and the script exits 0: a rotation problem
734
+ must never stop the node from starting.
735
+
736
+ Paths are embedded as single-quoted literals with every quote doubled,
737
+ which is PowerShell's only escape inside single quotes - no expansion of
738
+ $, backtick or anything else can happen to them.
739
+ #>
740
+ function New-LogRotationScript {
741
+ param(
742
+ [Parameter(Mandatory = $true)][string[]] $LogPaths,
743
+ [Parameter(Mandatory = $true)][long] $RotateBytes,
744
+ [Parameter(Mandatory = $true)][int] $Keep
745
+ )
746
+ $quoted = @($LogPaths | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ', '
747
+ $lines = @(
748
+ '$ErrorActionPreference = ''Stop''',
749
+ "foreach (`$log in @($quoted)) {",
750
+ ' try {',
751
+ ' $item = Get-Item -LiteralPath $log -ErrorAction SilentlyContinue',
752
+ " if (`$null -eq `$item -or `$item.Length -lt $RotateBytes) { continue }",
753
+ ' $stamp = (Get-Date).ToString(''yyyyMMddTHHmmss'')',
754
+ ' Move-Item -LiteralPath $log -Destination ($log + ''.'' + $stamp) -Force'
755
+ ) + (Get-LogPruneScriptLines -Keep $Keep) + @(
756
+ ' }',
757
+ ' catch { }',
758
+ '}',
759
+ 'exit 0'
760
+ )
761
+ return ($lines -join "`r`n")
762
+ }
763
+
764
+ <#
765
+ .SYNOPSIS
766
+ The lines that delete all but the newest Keep rotated generations of
767
+ the log in $log, as script text for the generated scripts.
768
+
769
+ .DESCRIPTION
770
+ Self-build slice AP (review). Matches BOTH naming schemes a rotated log
771
+ can have in the state directory, and nothing else:
772
+
773
+ * <leaf>.<yyyyMMddTHHmmss> - this installer's task branch
774
+ (node.log.20261008T034500);
775
+ * <base>-<yyyyMMddTHHmmss>[.fff]<ext> - NSSM's own AppRotate names
776
+ (node-20261008T034500.123.log,
777
+ node.err-20261008T034500.123.log).
778
+
779
+ Ordered by the timestamp IN THE NAME, newest first, not by
780
+ LastWriteTime: a rename keeps the file's old modification time, so the
781
+ generation just rotated would otherwise sort as the oldest and be the
782
+ one deleted. The live log itself never matches either pattern.
783
+ #>
784
+ function Get-LogPruneScriptLines {
785
+ param([Parameter(Mandatory = $true)][int] $Keep)
786
+ return @(
787
+ ' $leaf = Split-Path -Leaf $log',
788
+ ' $base = [IO.Path]::GetFileNameWithoutExtension($leaf)',
789
+ ' $ext = [IO.Path]::GetExtension($leaf)',
790
+ ' $pattern = ''^(?:'' + [regex]::Escape($leaf) + ''\.\d{8}T\d{6}|'' + [regex]::Escape($base) + ''-\d{8}T\d{6}(?:\.\d{3})?'' + [regex]::Escape($ext) + '')$''',
791
+ ' $rotated = @(Get-ChildItem -LiteralPath (Split-Path -Parent $log) -File | Where-Object { $_.Name -match $pattern } | Sort-Object { [regex]::Match($_.Name, ''\d{8}T\d{6}'').Value } -Descending)',
792
+ " if (`$rotated.Count -gt $Keep) { `$rotated | Select-Object -Skip $Keep | Remove-Item -Force }"
793
+ )
794
+ }
795
+
796
+ <#
797
+ .SYNOPSIS
798
+ The PowerShell that ONLY prunes old rotated generations of the logs
799
+ (it never rotates the live file), as text.
800
+
801
+ .DESCRIPTION
802
+ Self-build slice AP (review). NSSM's AppRotate* settings rotate the
803
+ service's logs but never delete a rotated generation, so on a node that
804
+ runs for months they accumulate without bound - the disk the workspace
805
+ floor guards. The service branch therefore also registers a small
806
+ scheduled task (New-ScheduledTaskPruneActionSpec) that runs this daily
807
+ and at boot, keeping the newest Keep generations of each log, the same
808
+ number the scheduled-task branch keeps. Every failure is swallowed and
809
+ the script exits 0.
810
+ #>
811
+ function New-LogPruneScript {
812
+ param(
813
+ [Parameter(Mandatory = $true)][string[]] $LogPaths,
814
+ [Parameter(Mandatory = $true)][int] $Keep
815
+ )
816
+ $quoted = @($LogPaths | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ', '
817
+ $lines = @(
818
+ '$ErrorActionPreference = ''Stop''',
819
+ "foreach (`$log in @($quoted)) {",
820
+ ' try {'
821
+ ) + (Get-LogPruneScriptLines -Keep $Keep) + @(
822
+ ' }',
823
+ ' catch { }',
824
+ '}',
825
+ 'exit 0'
826
+ )
827
+ return ($lines -join "`r`n")
828
+ }
829
+
830
+ <#
831
+ .SYNOPSIS
832
+ The service branch's log-prune task action (self-build slice AP review).
833
+
834
+ .DESCRIPTION
835
+ Same encoding as New-ScheduledTaskRotationActionSpec: -EncodedCommand,
836
+ so no path or quote in the script survives a second round of
837
+ command-line parsing.
838
+ #>
839
+ function New-ScheduledTaskPruneActionSpec {
840
+ param(
841
+ [Parameter(Mandatory = $true)][string] $PowerShellExe,
842
+ [Parameter(Mandatory = $true)][string[]] $LogPaths,
843
+ [Parameter(Mandatory = $true)][int] $Keep
844
+ )
845
+ $script = New-LogPruneScript -LogPaths $LogPaths -Keep $Keep
846
+ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script))
847
+ return [pscustomobject]@{
848
+ Execute = $PowerShellExe
849
+ Argument = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"
850
+ Script = $script
851
+ }
852
+ }
853
+
854
+ <#
855
+ .SYNOPSIS
856
+ The scheduled task's FIRST action: rotate the logs, then let the node
857
+ action start.
858
+
859
+ .DESCRIPTION
860
+ Self-build slice AP. The task branch writes through cmd.exe's >> (see
861
+ New-ScheduledTaskActionSpec), and a file cmd.exe holds open can be
862
+ neither renamed (no FILE_SHARE_DELETE) nor safely truncated (cmd keeps
863
+ its own write offset and would leave a hole of NULs). So this branch
864
+ rotates when the task STARTS - at boot and on every restart-on-failure -
865
+ as a separate action that runs to completion before the node action:
866
+ the Task Scheduler runs a task's actions one after another, in order.
867
+ Between starts the log still grows; NSSM's online rotation is one more
868
+ reason the service stays the recommended shape.
869
+
870
+ -EncodedCommand (base64 of the UTF-16LE script) rather than -Command so
871
+ no path, quote or $ in the script has to survive a second round of
872
+ command-line parsing. Windows PowerShell 5.1 is assumed and is in-box on
873
+ every supported Windows.
874
+ #>
875
+ function New-ScheduledTaskRotationActionSpec {
876
+ param(
877
+ [Parameter(Mandatory = $true)][string] $PowerShellExe,
878
+ [Parameter(Mandatory = $true)][string[]] $LogPaths,
879
+ [Parameter(Mandatory = $true)][long] $RotateBytes,
880
+ [Parameter(Mandatory = $true)][int] $Keep
881
+ )
882
+ $script = New-LogRotationScript -LogPaths $LogPaths -RotateBytes $RotateBytes -Keep $Keep
883
+ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script))
884
+ return [pscustomobject]@{
885
+ Execute = $PowerShellExe
886
+ Argument = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"
887
+ Script = $script
888
+ }
889
+ }
890
+
891
+ # --- Preflight ------------------------------------------------------------
892
+
893
+ function New-PreflightFinding {
894
+ param(
895
+ [Parameter(Mandatory = $true)][ValidateSet('error', 'warning')][string] $Severity,
896
+ [Parameter(Mandatory = $true)][string] $Code,
897
+ [Parameter(Mandatory = $true)][string] $Message
898
+ )
899
+ return [pscustomobject]@{ Severity = $Severity; Code = $Code; Message = $Message }
900
+ }
901
+
902
+ # A gMSA is provisioned, never signed in to, so "sign in once" is advice it
903
+ # can never take. Say the thing that IS true for the account in hand.
904
+ function Get-AccountProvisioningRemedy {
905
+ param([Parameter(Mandatory = $true)] $Identity)
906
+ if ($Identity.Kind -eq 'gmsa') {
907
+ return "A gMSA is provisioned rather than signed in to: run Install-ADServiceAccount on this machine, then create the profile and the enrollment by running the node once as that account (schtasks /create /ru $($Identity.SamAccountName) ...). A gMSA is an unusual choice for a fleet node, whose whole job is to run the ENROLLING user's work with that user's git and model-CLI logins."
908
+ }
909
+ return "Sign in once as $($Identity.SamAccountName) (enrollment has to happen there anyway), then re-run this installer."
910
+ }
911
+
912
+ <#
913
+ .SYNOPSIS
914
+ Decide whether this account can actually run a node, from facts the
915
+ installer has already gathered.
916
+
917
+ .DESCRIPTION
918
+ The failure this exists to prevent is silent: a node installed under
919
+ the wrong identity starts cleanly, finds no config, reports "not
920
+ enrolled" (exit 3), and is restarted every 10 seconds forever. Worse,
921
+ config-store.ts's loadConfig swallows a permission error and returns
922
+ null too, so an EPERM on the config file is indistinguishable in the
923
+ log from a machine that was never enrolled. Every check below therefore
924
+ names the exact path and the exact remedy rather than a generic
925
+ failure.
926
+
927
+ $Probe is a plain hashtable of already-measured facts so this function
928
+ stays pure. Access answers are three-valued - 'granted' / 'denied' /
929
+ 'unknown' - for the reasons spelled out on Get-AccessVerdictFromAcl.
930
+
931
+ Severity is chosen by ONE rule: block when the install could not work
932
+ and this script cannot fix it; warn when it can. That is why the state
933
+ directory and the log files warn - the installer grants the account
934
+ Modify on them - while an unreadable config, which only the enrolling
935
+ user can repair, refuses.
936
+ #>
937
+ function Get-ServiceInstallPreflightFinding {
938
+ param(
939
+ [Parameter(Mandatory = $true)] $Identity,
940
+ [Parameter(Mandatory = $true)][bool] $HasPassword,
941
+ [Parameter(Mandatory = $true)][ValidateSet('service', 'task')][string] $Mechanism,
942
+ [Parameter(Mandatory = $true)][hashtable] $Probe
943
+ )
944
+
945
+ $findings = [Collections.Generic.List[object]]::new()
946
+ $account = $Identity.SamAccountName
947
+
948
+ # 1. The identity itself ----------------------------------------------
949
+
950
+ if ($Identity.IsBuiltIn) {
951
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'built-in-service-account' -Message "$($Identity.Description) cannot run a fleet node. A node executes the enrolling user's work: its config lives in that user's %APPDATA%\ever-works-node, its git credentials in that user's Credential Manager vault, and the Claude Code / Codex logins in that user's profile. A built-in account has none of them. Pass -ServiceAccount <DOMAIN\User>."))
952
+ }
953
+
954
+ if ($Identity.RequiresPassword -and -not $HasPassword) {
955
+ if ($Mechanism -eq 'service') {
956
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'password-required' -Message "$($Identity.Description) needs a password to log on as a service; without one the service registers and then fails to start with error 1069. Pass -ServicePassword (Read-Host -AsSecureString)."))
957
+ }
958
+ else {
959
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 's4u-logon' -Message "No -ServicePassword, so the task will use an S4U logon. It runs as $account, but the token never unlocks that account's DPAPI master key: Windows Credential Manager reads - the git credential helper, and the node credential once @napi-rs/keyring is installed - report absent. Pass -ServicePassword for a real logon."))
960
+ }
961
+ }
962
+
963
+ if (-not $Identity.RequiresPassword -and $HasPassword) {
964
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'password-not-accepted' -Message "$($Identity.Description) derives its own password; supplying -ServicePassword for it is rejected by the Service Control Manager. Omit it."))
965
+ }
966
+
967
+ if (-not $Probe.AccountResolved) {
968
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'unknown-account' -Message "Windows does not recognise the account '$account' on this machine or in its domain. Check the spelling, or use .\<name> for a local account."))
969
+ # Nothing below can be measured without a SID, so stop here rather
970
+ # than emitting a cascade of derived complaints about one typo.
971
+ return , $findings.ToArray()
972
+ }
973
+
974
+ # 2. The profile every node path is derived from ----------------------
975
+
976
+ if ([string]::IsNullOrWhiteSpace($Probe.ProfilePath)) {
977
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'no-profile' -Message "$account has no user profile on this machine, so it has no %APPDATA% and cannot hold an enrolled node's config. $(Get-AccountProvisioningRemedy -Identity $Identity)"))
978
+ return , $findings.ToArray()
979
+ }
980
+
981
+ # 3. The node credential ----------------------------------------------
982
+
983
+ if (-not $Probe.ConfigExists) {
984
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'not-enrolled' -Message "No node config for $account at '$($Probe.ConfigPath)'. Enrollment is per-user and is deliberately not done by this installer. Run, as $account : ever-works-node enroll --api-url <url> --token <token>"))
985
+ }
986
+ elseif ($Probe.ConfigAccess -eq 'denied') {
987
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'config-unreadable' -Message "'$($Probe.ConfigPath)' exists but its ACL does not grant $account read access, and inheritance has been stripped from it (the node writes it with icacls /inheritance:r /grant:r <enrolling user>:(F), leaving exactly one ACE). The node would report this as 'not enrolled' rather than as a permission error. Enroll as $account, or grant it read: icacls `"$($Probe.ConfigPath)`" /grant `"$account`":(R)"))
988
+ }
989
+ elseif ($Probe.ConfigAccess -eq 'unknown') {
990
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'config-access-unproven' -Message "Could not prove from its ACL that $account can read '$($Probe.ConfigPath)'; the DACL still inherits, so access may come from a group this check cannot enumerate. If the node reports 'not enrolled' after install, check this first."))
991
+ }
992
+
993
+ if ($Probe.ConfigPathSource -eq 'environment-override') {
994
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'config-env-override' -Message "EVER_WORKS_NODE_CONFIG is set machine-wide and points the node at '$($Probe.ConfigPath)' instead of $account's %APPDATA%. That is honoured, but it means every account on this machine shares one node credential."))
995
+ }
996
+
997
+ # 4. The model CLIs ----------------------------------------------------
998
+
999
+ foreach ($cli in @(
1000
+ [pscustomobject]@{ Name = 'claude-code'; Flag = '-ClaudePath'; Path = $Probe.ClaudePath; State = $Probe.ClaudePathState; LoginPath = $Probe.ClaudeLoginPath; LoginExists = $Probe.ClaudeLoginExists },
1001
+ [pscustomobject]@{ Name = 'codex'; Flag = '-CodexPath'; Path = $Probe.CodexPath; State = $Probe.CodexPathState; LoginPath = $Probe.CodexLoginPath; LoginExists = $Probe.CodexLoginExists }
1002
+ )) {
1003
+ switch ($cli.State) {
1004
+ 'missing' {
1005
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code "$($cli.Name)-pin-missing" -Message "$($cli.Flag) '$($cli.Path)' does not exist. A pin that does not resolve DISABLES that CLI rather than falling back to PATH (model-cli-probe.ts), so the node would quietly stop advertising $($cli.Name)."))
1006
+ }
1007
+ 'not-launchable' {
1008
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code "$($cli.Name)-pin-not-launchable" -Message "$($cli.Flag) '$($cli.Path)' is not a launchable Windows executable. The node accepts only .cmd, .exe and .bat here (WINDOWS_LAUNCHABLE in model-cli-probe.ts); npm's extension-less shim is a bash script and cannot be spawned."))
1009
+ }
1010
+ 'unreadable' {
1011
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code "$($cli.Name)-pin-unreadable" -Message "$($cli.Flag) '$($cli.Path)' exists but its ACL does not grant $account access. The node would advertise $($cli.Name) and then fail to spawn it on every job."))
1012
+ }
1013
+ 'unpinned' {
1014
+ if ($Mechanism -eq 'service') {
1015
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code "$($cli.Name)-unpinned" -Message "No $($cli.Flag). The node will look for $($cli.Name) on PATH, but a service inherits the MACHINE environment and npm's global prefix (%APPDATA%\npm) is normally on the USER path only - so the capability will usually be absent. Pin it if this machine should take $($cli.Name) work."))
1016
+ }
1017
+ }
1018
+ }
1019
+ if ($cli.State -eq 'ok' -and -not $cli.LoginExists) {
1020
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code "$($cli.Name)-login-missing" -Message "$($cli.Flag) resolves, but $account has no $($cli.Name) login at '$($cli.LoginPath)'. The node will ADVERTISE $($cli.Name) on the strength of the executable and then fail every job it is sent, unauthenticated - the tag being present is worse than it being absent. Sign in as $account, or make sure the platform supplies a credential with each job."))
1021
+ }
1022
+ }
1023
+
1024
+ # 5. The workspace root ------------------------------------------------
1025
+
1026
+ if ($Probe.WorkspaceRootUnderSystemProfile) {
1027
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'workspace-root-in-system-profile' -Message "The per-Task worktrees would land in '$($Probe.WorkspaceRoot)', inside the protected system profile. That is writable but invisible to the operator, on the system volume, and adds MAX_PATH pressure to already-hashed worktree paths. Pass -WorkspaceRoot <D:\fleet-workspaces>."))
1028
+ }
1029
+ elseif ($Probe.WorkspaceRootAccess -eq 'denied') {
1030
+ $findings.Add((New-PreflightFinding -Severity 'error' -Code 'workspace-root-unwritable' -Message "$account cannot write '$($Probe.WorkspaceRoot)' (checked against '$($Probe.WorkspaceRootProbedAncestor)', the nearest existing ancestor). Every Task the node leases would fail to provision a worktree. Grant it write access or pass a different -WorkspaceRoot."))
1031
+ }
1032
+ elseif ($Probe.WorkspaceRootAccess -eq 'unknown') {
1033
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'workspace-root-access-unproven' -Message "Could not prove from its ACL that $account can write '$($Probe.WorkspaceRoot)' (checked '$($Probe.WorkspaceRootProbedAncestor)'). If Tasks fail to provision worktrees, check this first."))
1034
+ }
1035
+
1036
+ # 6. Where the logs go -------------------------------------------------
1037
+ #
1038
+ # Warnings, not errors, because the installer FIXES both: it grants the
1039
+ # account Modify on the directory and on any log file left behind by a
1040
+ # previous identity. They are still reported, because an operator
1041
+ # rehearsing five machines should see that a repair will happen - and
1042
+ # because if the repair itself fails, this is the line that explains the
1043
+ # failure that follows.
1044
+
1045
+ if ($Probe.StateDirAccess -eq 'denied') {
1046
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'state-dir-unwritable' -Message "'$($Probe.StateDir)' does not currently grant $account write access; the installer will grant it Modify. Without that the service cannot open its AppStdout, and the scheduled task dies inside cmd.exe before the node runs - leaving nothing in either log."))
1047
+ }
1048
+
1049
+ if ($Probe.UnwritableLogPaths.Count -gt 0) {
1050
+ $findings.Add((New-PreflightFinding -Severity 'warning' -Code 'log-unwritable' -Message "$($Probe.UnwritableLogPaths -join ', ') - written under a previous identity and readable but not writable by $account; the installer will grant it Modify. This is the normal state of a machine installed before the node ran as a real account."))
1051
+ }
1052
+
1053
+ return , $findings.ToArray()
1054
+ }
1055
+
1056
+ # Render the preflight verdict for the console. Errors first: an operator
1057
+ # checking five machines reads the top of the output, not the bottom.
1058
+ function Format-PreflightFindingReport {
1059
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]] $Findings)
1060
+ $lines = [Collections.Generic.List[string]]::new()
1061
+ foreach ($severity in @('error', 'warning')) {
1062
+ foreach ($finding in @($Findings | Where-Object { $_.Severity -eq $severity })) {
1063
+ $label = $severity.ToUpperInvariant()
1064
+ $lines.Add(" [$label] $($finding.Code): $($finding.Message)")
1065
+ }
1066
+ }
1067
+ return , $lines.ToArray()
1068
+ }
1069
+
1070
+ function Test-PreflightBlocked {
1071
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]] $Findings)
1072
+ return (@($Findings | Where-Object { $_.Severity -eq 'error' }).Count -gt 0)
1073
+ }
1074
+
1075
+ # --- Service identity interop ---------------------------------------------
1076
+
1077
+ <#
1078
+ .SYNOPSIS
1079
+ The C# the installer compiles to set a service's logon account without
1080
+ putting the password on any command line.
1081
+
1082
+ .DESCRIPTION
1083
+ Returned as a string rather than compiled here, so this file keeps its
1084
+ no-side-effects-at-load rule and the test harness can compile the same
1085
+ text the installer does and exercise the P/Invoke signatures.
1086
+
1087
+ WHY NOT `nssm set <name> ObjectName <account> <password>`, which is what
1088
+ this script used to do: that is an argument on a child process's command
1089
+ line, and every mechanism that records process creation records it -
1090
+ Security event 4688 wherever ProcessCreationIncludeCmdLine_Enabled is
1091
+ set (the Microsoft and CIS baselines set it), and any EDR agent that
1092
+ captures ProcessCommandLine, which Defender for Endpoint uploads to its
1093
+ cloud and retains for Advanced Hunting. The exposure is persistent and
1094
+ can leave the machine. ChangeServiceConfigW takes the password as a
1095
+ pointer to unmanaged memory instead: no child process, no cmdlet
1096
+ parameter binding, and no managed String that survives on the GC heap.
1097
+
1098
+ WHAT NSSM WAS ALSO DOING FOR US: granting SeServiceLogonRight. Verified
1099
+ in nssm.exe 2.24-101, which imports LsaOpenPolicy/LsaAddAccountRights
1100
+ and carries the SeServiceLogonRight string. ChangeServiceConfig does NOT
1101
+ grant it, and without it the service configures cleanly and then fails
1102
+ to start with error 1069 - so GrantServiceLogonRight below is not
1103
+ optional, it is the half of the job NSSM was quietly covering.
1104
+
1105
+ The ProbeXxxSignature methods exist so the marshalling can be tested on
1106
+ a machine that must not be modified: each passes a deliberately invalid
1107
+ handle, which exercises the full signature and returns a documented
1108
+ error without reaching any service or the local security policy.
1109
+ #>
1110
+ function Get-ServiceIdentityInteropSource {
1111
+ return @'
1112
+ using System;
1113
+ using System.ComponentModel;
1114
+ using System.Runtime.InteropServices;
1115
+
1116
+ public static class EverWorksServiceIdentity
1117
+ {
1118
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
1119
+ private static extern IntPtr OpenSCManagerW(string machineName, string databaseName, uint access);
1120
+
1121
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
1122
+ private static extern IntPtr OpenServiceW(IntPtr scManager, string serviceName, uint access);
1123
+
1124
+ [DllImport("advapi32.dll", SetLastError = true)]
1125
+ [return: MarshalAs(UnmanagedType.Bool)]
1126
+ private static extern bool CloseServiceHandle(IntPtr handle);
1127
+
1128
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
1129
+ [return: MarshalAs(UnmanagedType.Bool)]
1130
+ private static extern bool ChangeServiceConfigW(
1131
+ IntPtr service,
1132
+ uint serviceType,
1133
+ uint startType,
1134
+ uint errorControl,
1135
+ string binaryPathName,
1136
+ string loadOrderGroup,
1137
+ IntPtr tagId,
1138
+ string dependencies,
1139
+ string serviceStartName,
1140
+ IntPtr password,
1141
+ string displayName);
1142
+
1143
+ [StructLayout(LayoutKind.Sequential)]
1144
+ private struct LSA_UNICODE_STRING
1145
+ {
1146
+ public ushort Length;
1147
+ public ushort MaximumLength;
1148
+ public IntPtr Buffer;
1149
+ }
1150
+
1151
+ [StructLayout(LayoutKind.Sequential)]
1152
+ private struct LSA_OBJECT_ATTRIBUTES
1153
+ {
1154
+ public int Length;
1155
+ public IntPtr RootDirectory;
1156
+ public IntPtr ObjectName;
1157
+ public uint Attributes;
1158
+ public IntPtr SecurityDescriptor;
1159
+ public IntPtr SecurityQualityOfService;
1160
+ }
1161
+
1162
+ [DllImport("advapi32.dll", SetLastError = true)]
1163
+ private static extern uint LsaOpenPolicy(
1164
+ IntPtr systemName, ref LSA_OBJECT_ATTRIBUTES objectAttributes, uint desiredAccess, out IntPtr policyHandle);
1165
+
1166
+ [DllImport("advapi32.dll", SetLastError = true)]
1167
+ private static extern uint LsaAddAccountRights(
1168
+ IntPtr policyHandle, byte[] accountSid, LSA_UNICODE_STRING[] userRights, uint countOfRights);
1169
+
1170
+ [DllImport("advapi32.dll")]
1171
+ private static extern uint LsaClose(IntPtr policyHandle);
1172
+
1173
+ [DllImport("advapi32.dll")]
1174
+ private static extern int LsaNtStatusToWinError(uint status);
1175
+
1176
+ private const uint SC_MANAGER_CONNECT = 0x0001;
1177
+ private const uint SERVICE_CHANGE_CONFIG = 0x0002;
1178
+ private const uint SERVICE_NO_CHANGE = 0xFFFFFFFF;
1179
+ private const uint POLICY_CREATE_ACCOUNT = 0x0010;
1180
+ private const uint POLICY_LOOKUP_NAMES = 0x0800;
1181
+
1182
+ // Without SeServiceLogonRight the SCM refuses to start the service with
1183
+ // error 1069 no matter how correct the account and password are.
1184
+ public static void GrantServiceLogonRight(byte[] accountSid)
1185
+ {
1186
+ LSA_OBJECT_ATTRIBUTES attributes = new LSA_OBJECT_ATTRIBUTES();
1187
+ attributes.Length = Marshal.SizeOf(typeof(LSA_OBJECT_ATTRIBUTES));
1188
+ IntPtr policy;
1189
+ uint status = LsaOpenPolicy(
1190
+ IntPtr.Zero, ref attributes, POLICY_CREATE_ACCOUNT | POLICY_LOOKUP_NAMES, out policy);
1191
+ if (status != 0)
1192
+ {
1193
+ throw new Win32Exception(
1194
+ LsaNtStatusToWinError(status),
1195
+ "Could not open the local security policy to grant the \"Log on as a service\" right");
1196
+ }
1197
+ try
1198
+ {
1199
+ LSA_UNICODE_STRING[] rights = new LSA_UNICODE_STRING[1];
1200
+ rights[0] = ToLsaString("SeServiceLogonRight");
1201
+ try
1202
+ {
1203
+ status = LsaAddAccountRights(policy, accountSid, rights, 1);
1204
+ if (status != 0)
1205
+ {
1206
+ throw new Win32Exception(
1207
+ LsaNtStatusToWinError(status),
1208
+ "Could not grant the \"Log on as a service\" right to the service account");
1209
+ }
1210
+ }
1211
+ finally { Marshal.FreeHGlobal(rights[0].Buffer); }
1212
+ }
1213
+ finally { LsaClose(policy); }
1214
+ }
1215
+
1216
+ // password points at unmanaged UTF-16 owned and zeroed by the caller. An
1217
+ // EMPTY string is passed for gMSAs and virtual accounts; a NULL pointer
1218
+ // would mean "leave the stored password alone", which is a different
1219
+ // thing entirely.
1220
+ public static void SetServiceLogonAccount(string serviceName, string account, IntPtr password)
1221
+ {
1222
+ IntPtr manager = OpenSCManagerW(null, null, SC_MANAGER_CONNECT);
1223
+ if (manager == IntPtr.Zero)
1224
+ {
1225
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not open the Service Control Manager");
1226
+ }
1227
+ try
1228
+ {
1229
+ IntPtr service = OpenServiceW(manager, serviceName, SERVICE_CHANGE_CONFIG);
1230
+ if (service == IntPtr.Zero)
1231
+ {
1232
+ throw new Win32Exception(
1233
+ Marshal.GetLastWin32Error(), "Could not open the service '" + serviceName + "' to change its logon account");
1234
+ }
1235
+ try
1236
+ {
1237
+ bool ok = ChangeServiceConfigW(
1238
+ service, SERVICE_NO_CHANGE, SERVICE_NO_CHANGE, SERVICE_NO_CHANGE,
1239
+ null, null, IntPtr.Zero, null, account, password, null);
1240
+ if (!ok)
1241
+ {
1242
+ throw new Win32Exception(
1243
+ Marshal.GetLastWin32Error(), "Could not set the logon account of '" + serviceName + "' to '" + account + "'");
1244
+ }
1245
+ }
1246
+ finally { CloseServiceHandle(service); }
1247
+ }
1248
+ finally { CloseServiceHandle(manager); }
1249
+ }
1250
+
1251
+ // Signature probes. Both pass an invalid handle on purpose: they prove
1252
+ // the P/Invoke marshals without touching a service or the policy.
1253
+ public static int ProbeChangeServiceConfigSignature()
1254
+ {
1255
+ IntPtr password = Marshal.StringToCoTaskMemUni("");
1256
+ try
1257
+ {
1258
+ bool ok = ChangeServiceConfigW(
1259
+ IntPtr.Zero, SERVICE_NO_CHANGE, SERVICE_NO_CHANGE, SERVICE_NO_CHANGE,
1260
+ null, null, IntPtr.Zero, null, ".\\nobody", password, null);
1261
+ if (ok) { throw new InvalidOperationException("ChangeServiceConfigW accepted a null service handle"); }
1262
+ return Marshal.GetLastWin32Error();
1263
+ }
1264
+ finally { Marshal.ZeroFreeCoTaskMemUnicode(password); }
1265
+ }
1266
+
1267
+ public static uint ProbeAddAccountRightsSignature(byte[] accountSid)
1268
+ {
1269
+ LSA_UNICODE_STRING[] rights = new LSA_UNICODE_STRING[1];
1270
+ rights[0] = ToLsaString("SeServiceLogonRight");
1271
+ try { return LsaAddAccountRights(IntPtr.Zero, accountSid, rights, 1); }
1272
+ finally { Marshal.FreeHGlobal(rights[0].Buffer); }
1273
+ }
1274
+
1275
+ private static LSA_UNICODE_STRING ToLsaString(string value)
1276
+ {
1277
+ LSA_UNICODE_STRING result = new LSA_UNICODE_STRING();
1278
+ result.Buffer = Marshal.StringToHGlobalUni(value);
1279
+ result.Length = (ushort)(value.Length * 2);
1280
+ result.MaximumLength = (ushort)(value.Length * 2 + 2);
1281
+ return result;
1282
+ }
1283
+ }
1284
+ '@
1285
+ }