@geekbeer/minion 3.5.1 → 3.5.30

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.5.1",
3
+ "version": "3.5.30",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "hq-win": "./win/bin/hq-win.js"
11
11
  },
12
12
  "files": [
13
+ "postinstall.js",
13
14
  "core/",
14
15
  "linux/",
15
16
  "win/",
@@ -22,7 +23,8 @@
22
23
  ],
23
24
  "scripts": {
24
25
  "start": "node linux/server.js",
25
- "start:win": "node win/server.js"
26
+ "start:win": "node win/server.js",
27
+ "postinstall": "node postinstall.js"
26
28
  },
27
29
  "dependencies": {
28
30
  "better-sqlite3": "^11.0.0",
package/postinstall.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ // postinstall.js — Re-apply file ACLs on Windows after npm install -g.
3
+ // On Linux this is a no-op (permissions are not affected by reinstall).
4
+
5
+ if (process.platform !== 'win32') process.exit(0);
6
+
7
+ const { spawnSync } = require('child_process');
8
+ const path = require('path');
9
+
10
+ const ps1 = path.join(__dirname, 'win', 'postinstall.ps1');
11
+ const result = spawnSync('powershell.exe', [
12
+ '-ExecutionPolicy', 'Bypass',
13
+ '-NoProfile',
14
+ '-NoLogo',
15
+ '-File', ps1,
16
+ ], { stdio: 'inherit', timeout: 30_000 });
17
+
18
+ process.exit(result.status || 0);
@@ -35,6 +35,20 @@ function getNssmPath() {
35
35
  return vendorNssm
36
36
  }
37
37
 
38
+ /**
39
+ * Derive the npm global prefix from the nssm.exe path.
40
+ * nssm is bundled at: <npm-prefix>/node_modules/@geekbeer/minion/win/vendor/nssm.exe
41
+ * So the prefix is the ancestor directory containing node_modules.
42
+ * @param {string} nssmPath - Absolute path to nssm.exe
43
+ * @returns {string|null} npm global prefix, or null if not derivable
44
+ */
45
+ function getNpmPrefix(nssmPath) {
46
+ const parts = nssmPath.split(path.sep)
47
+ const nmIndex = parts.lastIndexOf('node_modules')
48
+ if (nmIndex > 0) return parts.slice(0, nmIndex).join(path.sep)
49
+ return null
50
+ }
51
+
38
52
  /**
39
53
  * Detect Windows process manager. Always returns 'nssm'.
40
54
  * @returns {'nssm'}
@@ -56,7 +70,7 @@ function buildGracefulStopBlock(agentPort, apiToken) {
56
70
  return [
57
71
  ` try {`,
58
72
  ` Invoke-RestMethod -Uri 'http://localhost:${agentPort}/api/shutdown' -Method POST ` +
59
- `-ContentType 'application/json' -Headers @{ Authorization = 'Bearer ${apiToken}' } -TimeoutSec 5 | Out-Null`,
73
+ `-Body '{}' -ContentType 'application/json' -Headers @{ Authorization = 'Bearer ${apiToken}' } -TimeoutSec 5 | Out-Null`,
60
74
  ` Log 'Graceful shutdown requested, waiting for offline heartbeat...'`,
61
75
  ` Start-Sleep -Seconds 4`,
62
76
  ` } catch {`,
@@ -71,6 +85,11 @@ function buildGracefulStopBlock(agentPort, apiToken) {
71
85
  * 2. Stop service via NSSM
72
86
  * 3. Run npm install -g
73
87
  * 4. Start service via NSSM
88
+ * 5. Remove the temporary updater service (self-cleanup)
89
+ *
90
+ * This script is registered as a temporary NSSM service ("minion-update")
91
+ * so it runs independently of the minion-agent service and survives
92
+ * the agent's stop/restart cycle.
74
93
  *
75
94
  * @param {string} npmInstallCmd - The npm install command to run
76
95
  * @param {string} nssmPath - Absolute path to nssm.exe
@@ -81,34 +100,63 @@ function buildGracefulStopBlock(agentPort, apiToken) {
81
100
  */
82
101
  function buildUpdateScript(npmInstallCmd, nssmPath, scriptName = 'update-agent', agentPort = 8080, apiToken = '') {
83
102
  const dataDir = path.join(os.homedir(), '.minion')
103
+ const homeDir = os.homedir()
104
+ const npmPrefix = getNpmPrefix(nssmPath)
84
105
  const scriptPath = path.join(dataDir, `${scriptName}.ps1`)
85
- const logPath = path.join(dataDir, `${scriptName}.log`)
86
- const nssm = nssmPath.replace(/\\/g, '\\\\')
106
+ const logDir = path.join(dataDir, 'logs')
107
+ const logPath = path.join(logDir, `${scriptName}.log`)
108
+
109
+ // Append --prefix to npm command so it installs to the correct global directory
110
+ const fullNpmCmd = npmPrefix
111
+ ? `${npmInstallCmd} --prefix "${npmPrefix}"`
112
+ : npmInstallCmd
87
113
 
88
114
  const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
89
115
 
90
116
  const ps1 = [
117
+ `# Override LocalSystem's default profile so npm uses the correct paths`,
118
+ `$env:USERPROFILE = '${homeDir}'`,
119
+ `$env:HOME = '${homeDir}'`,
91
120
  `$ErrorActionPreference = 'Stop'`,
92
- `$logFile = '${logPath.replace(/\\/g, '\\\\')}'`,
121
+ `# Use nssm.exe from data dir (copied there to avoid EBUSY on package files)`,
122
+ `$nssm = '${path.join(dataDir, 'nssm.exe')}'`,
123
+ `$logFile = '${logPath}'`,
93
124
  `function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
94
125
  `Log 'Update started'`,
95
126
  `try {`,
96
127
  ` Log 'Requesting graceful shutdown...'`,
97
128
  gracefulStop,
98
129
  ` Log 'Stopping service via NSSM...'`,
99
- ` & '${nssm}' stop minion-agent`,
130
+ ` & $nssm stop minion-agent`,
100
131
  ` Start-Sleep -Seconds 3`,
132
+ ` # Retry npm install up to 5 times (Windows may hold file locks briefly after service stop)`,
101
133
  ` Log 'Installing package...'`,
102
- ` $out = & cmd /c "${npmInstallCmd} 2>&1"`,
103
- ` Log "npm output: $out"`,
104
- ` if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit code $LASTEXITCODE)" }`,
134
+ ` $installed = $false`,
135
+ ` for ($attempt = 1; $attempt -le 5; $attempt++) {`,
136
+ ` $out = & cmd /c "${fullNpmCmd} 2>&1"`,
137
+ ` if ($LASTEXITCODE -eq 0) {`,
138
+ ` Log "npm output: $out"`,
139
+ ` $installed = $true`,
140
+ ` break`,
141
+ ` }`,
142
+ ` Log "npm install attempt $attempt failed (exit $LASTEXITCODE): $out"`,
143
+ ` if ($attempt -lt 5) {`,
144
+ ` Log "Retrying in 10 seconds..."`,
145
+ ` Start-Sleep -Seconds 10`,
146
+ ` }`,
147
+ ` }`,
148
+ ` if (-not $installed) { throw "npm install failed after 5 attempts" }`,
105
149
  ` Log 'Starting service...'`,
106
- ` & '${nssm}' start minion-agent`,
150
+ ` & $nssm start minion-agent`,
107
151
  ` Log 'Update completed successfully'`,
108
152
  `} catch {`,
109
153
  ` Log "Update failed: $_"`,
110
154
  ` Log 'Attempting to start service anyway...'`,
111
- ` & '${nssm}' start minion-agent`,
155
+ ` & $nssm start minion-agent`,
156
+ `} finally {`,
157
+ ` Log 'Cleaning up updater service...'`,
158
+ ` & $nssm stop minion-update confirm 2>$null`,
159
+ ` & $nssm remove minion-update confirm 2>$null`,
112
160
  `}`,
113
161
  ].join('\n')
114
162
 
@@ -122,35 +170,47 @@ function buildUpdateScript(npmInstallCmd, nssmPath, scriptName = 'update-agent',
122
170
  * Generate a temporary PowerShell restart script:
123
171
  * 1. Graceful shutdown via HTTP API (offline heartbeat)
124
172
  * 2. Restart service via NSSM
173
+ * 3. Remove the temporary updater service (self-cleanup)
174
+ *
175
+ * Registered as a temporary NSSM service ("minion-update") so the restart
176
+ * process is independent of the minion-agent service.
125
177
  *
126
178
  * @param {string} nssmPath - Absolute path to nssm.exe
127
179
  * @param {number} agentPort - The agent's HTTP port
128
180
  * @param {string} apiToken - The agent's API token
129
181
  * @returns {string} - Path to the generated restart script (.ps1)
130
182
  */
131
- function buildRestartScript(nssmPath, agentPort, apiToken) {
183
+ function buildRestartScript(_nssmPath, agentPort, apiToken) {
132
184
  const dataDir = path.join(os.homedir(), '.minion')
185
+ const homeDir = os.homedir()
133
186
  const scriptPath = path.join(dataDir, 'restart-agent.ps1')
134
- const logPath = path.join(dataDir, 'restart-agent.log')
135
- const nssm = nssmPath.replace(/\\/g, '\\\\')
187
+ const logDir = path.join(dataDir, 'logs')
188
+ const logPath = path.join(logDir, 'restart-agent.log')
136
189
 
137
190
  const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
138
191
 
139
192
  const ps1 = [
193
+ `$env:USERPROFILE = '${homeDir}'`,
194
+ `$env:HOME = '${homeDir}'`,
140
195
  `$ErrorActionPreference = 'Stop'`,
141
- `$logFile = '${logPath.replace(/\\/g, '\\\\')}'`,
196
+ `$nssm = '${path.join(dataDir, 'nssm.exe')}'`,
197
+ `$logFile = '${logPath}'`,
142
198
  `function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
143
199
  `Log 'Restart started'`,
144
200
  `try {`,
145
201
  ` Log 'Requesting graceful shutdown...'`,
146
202
  gracefulStop,
147
203
  ` Log 'Restarting service via NSSM...'`,
148
- ` & '${nssm}' restart minion-agent`,
204
+ ` & $nssm restart minion-agent`,
149
205
  ` Log 'Restart completed successfully'`,
150
206
  `} catch {`,
151
207
  ` Log "Restart failed: $_"`,
152
208
  ` Log 'Attempting to start service...'`,
153
- ` & '${nssm}' start minion-agent`,
209
+ ` & $nssm start minion-agent`,
210
+ `} finally {`,
211
+ ` Log 'Cleaning up updater service...'`,
212
+ ` & $nssm stop minion-update confirm 2>$null`,
213
+ ` & $nssm remove minion-update confirm 2>$null`,
154
214
  `}`,
155
215
  ].join('\n')
156
216
 
@@ -162,9 +222,15 @@ function buildRestartScript(nssmPath, agentPort, apiToken) {
162
222
 
163
223
  /**
164
224
  * Build allowed commands for NSSM-based service management.
225
+ *
226
+ * Deferred commands (update/restart) use a temporary NSSM service
227
+ * ("minion-update") to run PowerShell scripts independently of the
228
+ * minion-agent service. This allows the script to stop, update, and
229
+ * restart minion-agent without being killed in the process.
230
+ *
165
231
  * @param {string} _procMgr - Ignored (always 'nssm')
166
232
  * @param {{ AGENT_PORT?: number, API_TOKEN?: string }} [agentConfig] - Agent config
167
- * @returns {Record<string, { description: string; command?: string; spawnArgs?: [string, string[]]; deferred?: boolean }>}
233
+ * @returns {Record<string, { description: string; command?: string; nssmService?: { scriptPath: string }; deferred?: boolean }>}
168
234
  */
169
235
  function buildAllowedCommands(_procMgr, agentConfig = {}) {
170
236
  const commands = {}
@@ -174,28 +240,29 @@ function buildAllowedCommands(_procMgr, agentConfig = {}) {
174
240
 
175
241
  commands['restart-agent'] = {
176
242
  description: 'Restart the minion agent service',
177
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
178
- `& '${buildRestartScript(nssmPath, agentPort, apiToken)}'`]],
243
+ nssmService: { scriptPath: buildRestartScript(nssmPath, agentPort, apiToken) },
179
244
  deferred: true,
180
245
  }
181
246
 
182
247
  commands['update-agent'] = {
183
248
  description: 'Update @geekbeer/minion to latest version and restart',
184
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
185
- `& '${buildUpdateScript(
249
+ nssmService: {
250
+ scriptPath: buildUpdateScript(
186
251
  'npm install -g @geekbeer/minion@latest',
187
252
  nssmPath, 'update-agent', agentPort, apiToken,
188
- )}'`]],
253
+ ),
254
+ },
189
255
  deferred: true,
190
256
  }
191
257
 
192
258
  commands['update-agent-dev'] = {
193
259
  description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
194
- spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
195
- `& '${buildUpdateScript(
260
+ nssmService: {
261
+ scriptPath: buildUpdateScript(
196
262
  'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873',
197
263
  nssmPath, 'update-agent-dev', agentPort, apiToken,
198
- )}'`]],
264
+ ),
265
+ },
199
266
  deferred: true,
200
267
  }
201
268
 
@@ -35,8 +35,6 @@ while ($i -lt $args.Count) {
35
35
 
36
36
  $ErrorActionPreference = 'Stop'
37
37
 
38
- # Load System.Web for password generation (used in setup)
39
- Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue
40
38
 
41
39
  # ============================================================
42
40
  # Require Administrator for service management commands
@@ -280,7 +278,7 @@ function Invoke-HealthCheck {
280
278
  function Assert-NssmAvailable {
281
279
  if (-not $NssmPath -or -not (Test-Path $NssmPath)) {
282
280
  Write-Error "NSSM not found. Expected at: $vendorNssm"
283
- Write-Host " Reinstall the package: npm install -g @geekbeer/minion" -ForegroundColor Yellow
281
+ Write-Host " Reinstall the package (admin PowerShell): npm install -g @geekbeer/minion" -ForegroundColor Yellow
284
282
  exit 1
285
283
  }
286
284
  }
@@ -362,7 +360,13 @@ function Stop-MinionService {
362
360
  Write-Host "minion-agent: not installed" -ForegroundColor Red
363
361
  return
364
362
  }
365
- # Graceful shutdown via HTTP API first (sends offline heartbeat to HQ)
363
+ if ($state -eq 'STOPPED') {
364
+ Write-Host "minion-agent service is already stopped"
365
+ return
366
+ }
367
+ # Trigger graceful shutdown (sends offline heartbeat to HQ) but do NOT wait
368
+ # for the process to exit — otherwise NSSM sees the exit as a crash and
369
+ # restarts the process before sc.exe stop can run.
366
370
  $token = ''
367
371
  if (Test-Path $EnvFile) {
368
372
  $envVars = Read-EnvFile $EnvFile
@@ -371,13 +375,18 @@ function Stop-MinionService {
371
375
  try {
372
376
  $headers = @{}
373
377
  if ($token) { $headers['Authorization'] = "Bearer $token" }
374
- Invoke-RestMethod -Uri "$AgentUrl/api/shutdown" -Method POST -ContentType 'application/json' -Headers $headers -TimeoutSec 5 | Out-Null
375
- Write-Host "Graceful shutdown requested, waiting..."
376
- Start-Sleep -Seconds 4
378
+ Invoke-RestMethod -Uri "$AgentUrl/api/shutdown" -Method POST -ContentType 'application/json' -Headers $headers -TimeoutSec 3 | Out-Null
377
379
  } catch {
378
- Write-Host "Graceful shutdown skipped (agent may not be running)"
380
+ # Agent may not be responding — sc.exe stop will handle it
379
381
  }
382
+ # Immediately stop via NSSM (prevents auto-restart, sends kill if still alive)
380
383
  sc.exe stop minion-agent 2>&1 | Out-Null
384
+ # Wait for service to fully stop (up to 10 seconds)
385
+ for ($i = 0; $i -lt 10; $i++) {
386
+ $s = Get-ServiceState 'minion-agent'
387
+ if ($s -eq 'STOPPED') { break }
388
+ Start-Sleep -Seconds 1
389
+ }
381
390
  Write-Host "minion-agent service stopped"
382
391
  }
383
392
 
@@ -392,7 +401,7 @@ function Restart-MinionService {
392
401
  # ============================================================
393
402
 
394
403
  function Invoke-Setup {
395
- $totalSteps = 10
404
+ $totalSteps = 11
396
405
 
397
406
  # Minionization warning
398
407
  Write-Host ""
@@ -403,7 +412,6 @@ function Invoke-Setup {
403
412
 
404
413
  Write-Host " This setup will:" -ForegroundColor Yellow
405
414
  Write-Host " - Install and configure software (Node.js, Claude Code, VNC)"
406
- Write-Host " - Create dedicated 'minion' service account"
407
415
  Write-Host " - Register Windows Services via NSSM"
408
416
  Write-Host " - Configure firewall rules"
409
417
  Write-Host ""
@@ -431,6 +439,14 @@ function Invoke-Setup {
431
439
 
432
440
  # Save setup user's SID for SDDL grants (so non-admin can control services later)
433
441
  $setupUserSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent().User).Value
442
+ # Also resolve target user's SID (may differ from setup user when run from admin account)
443
+ $targetUserName = Split-Path $TargetUserProfile -Leaf
444
+ try {
445
+ $targetUserSid = (New-Object System.Security.Principal.NTAccount($targetUserName)).Translate(
446
+ [System.Security.Principal.SecurityIdentifier]).Value
447
+ } catch {
448
+ $targetUserSid = $null
449
+ }
434
450
  New-Item -Path $DataDir -ItemType Directory -Force | Out-Null
435
451
  [System.IO.File]::WriteAllText((Join-Path $DataDir '.setup-user-sid'), $setupUserSid)
436
452
  # Save target user profile so configure/uninstall can find it
@@ -571,74 +587,8 @@ function Invoke-Setup {
571
587
  Write-Host " Please run 'claude' in a terminal to complete the authentication process." -ForegroundColor Yellow
572
588
  Write-Host ""
573
589
 
574
- # Step 4: Create dedicated 'minion' service account
575
- Write-Step 4 $totalSteps "Creating dedicated service account..."
576
- $MinionSvcUser = 'minion'
577
- $MinionSvcUserFull = ".\$MinionSvcUser"
578
- $minionUserExists = [bool](Get-LocalUser -Name $MinionSvcUser -ErrorAction SilentlyContinue)
579
- if ($minionUserExists) {
580
- Write-Detail "Service account '$MinionSvcUser' already exists"
581
- } else {
582
- # Generate a random password (service account — not used interactively)
583
- $svcPassword = [System.Web.Security.Membership]::GeneratePassword(24, 4)
584
- $securePassword = ConvertTo-SecureString $svcPassword -AsPlainText -Force
585
- New-LocalUser -Name $MinionSvcUser -Password $securePassword -Description 'Minion Agent Service Account' -PasswordNeverExpires -UserMayNotChangePassword -AccountNeverExpires | Out-Null
586
- # Deny interactive/remote logon (service-only account)
587
- & net localgroup "Users" $MinionSvcUser /delete 2>$null
588
- Write-Detail "Service account '$MinionSvcUser' created (non-interactive)"
589
- }
590
- # Store password for NSSM ObjectName configuration
591
- if (-not $minionUserExists) {
592
- # Save password to a protected file for NSSM service registration
593
- $svcPasswordFile = Join-Path $DataDir '.svc-password'
594
- New-Item -Path (Split-Path $svcPasswordFile) -ItemType Directory -Force | Out-Null
595
- [System.IO.File]::WriteAllText($svcPasswordFile, $svcPassword)
596
- # Restrict file access to current user only
597
- $acl = Get-Acl $svcPasswordFile
598
- $acl.SetAccessRuleProtection($true, $false)
599
- $adminRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
600
- [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, 'FullControl', 'Allow')
601
- $acl.AddAccessRule($adminRule)
602
- Set-Acl $svcPasswordFile $acl
603
- Write-Detail "Service account credentials stored"
604
- } else {
605
- $svcPasswordFile = Join-Path $DataDir '.svc-password'
606
- if (Test-Path $svcPasswordFile) {
607
- $svcPassword = [System.IO.File]::ReadAllText($svcPasswordFile).Trim()
608
- } else {
609
- # Re-generate password for existing account (reset)
610
- $svcPassword = [System.Web.Security.Membership]::GeneratePassword(24, 4)
611
- $securePassword = ConvertTo-SecureString $svcPassword -AsPlainText -Force
612
- Set-LocalUser -Name $MinionSvcUser -Password $securePassword
613
- New-Item -Path (Split-Path $svcPasswordFile) -ItemType Directory -Force | Out-Null
614
- [System.IO.File]::WriteAllText($svcPasswordFile, $svcPassword)
615
- $acl = Get-Acl $svcPasswordFile
616
- $acl.SetAccessRuleProtection($true, $false)
617
- $adminRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
618
- [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, 'FullControl', 'Allow')
619
- $acl.AddAccessRule($adminRule)
620
- Set-Acl $svcPasswordFile $acl
621
- Write-Detail "Service account password reset and stored"
622
- }
623
- }
624
- # Grant 'Log on as a service' right to the minion user
625
- $tempCfg = Join-Path $env:TEMP 'minion-secedit.cfg'
626
- $tempDb = Join-Path $env:TEMP 'minion-secedit.sdb'
627
- & secedit /export /cfg $tempCfg /areas USER_RIGHTS 2>$null
628
- $cfgContent = Get-Content $tempCfg -Raw
629
- if ($cfgContent -match 'SeServiceLogonRight\s*=\s*(.*)') {
630
- $existing = $Matches[1]
631
- if ($existing -notmatch $MinionSvcUser) {
632
- $cfgContent = $cfgContent -replace "(SeServiceLogonRight\s*=\s*)(.*)", "`$1`$2,$MinionSvcUser"
633
- [System.IO.File]::WriteAllText($tempCfg, $cfgContent)
634
- & secedit /configure /db $tempDb /cfg $tempCfg /areas USER_RIGHTS 2>$null
635
- Write-Detail "Granted 'Log on as a service' right to '$MinionSvcUser'"
636
- }
637
- }
638
- Remove-Item $tempCfg, $tempDb -Force -ErrorAction SilentlyContinue
639
-
640
- # Step 5: Create config directory and default .env
641
- Write-Step 5 $totalSteps "Creating config directory..."
590
+ # Step 4: Create config directory and default .env
591
+ Write-Step 4 $totalSteps "Creating config directory..."
642
592
  New-Item -Path $DataDir -ItemType Directory -Force | Out-Null
643
593
  New-Item -Path $LogDir -ItemType Directory -Force | Out-Null
644
594
  if (-not (Test-Path $EnvFile)) {
@@ -653,16 +603,8 @@ function Invoke-Setup {
653
603
  Write-Detail "$EnvFile already exists, preserving"
654
604
  }
655
605
 
656
- # Grant minion service account read/write access to data directory
657
- $minionAcl = Get-Acl $DataDir
658
- $minionRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
659
- $MinionSvcUser, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
660
- $minionAcl.AddAccessRule($minionRule)
661
- Set-Acl $DataDir $minionAcl
662
- Write-Detail "Granted '$MinionSvcUser' access to $DataDir"
663
-
664
- # Step 6: Install node-pty (required for Windows terminal management)
665
- Write-Step 6 $totalSteps "Installing terminal support (node-pty)..."
606
+ # Step 5: Install node-pty (required for Windows terminal management)
607
+ Write-Step 5 $totalSteps "Installing terminal support (node-pty)..."
666
608
  $minionPkgDir = $CliDir
667
609
  if (Test-Path $minionPkgDir) {
668
610
  Push-Location $minionPkgDir
@@ -696,22 +638,22 @@ function Invoke-Setup {
696
638
  }
697
639
  else {
698
640
  Write-Warn "Minion package not found at $minionPkgDir"
699
- Write-Host " Please run: npm install -g @geekbeer/minion"
641
+ Write-Host " Please run (admin PowerShell): npm install -g @geekbeer/minion"
700
642
  }
701
643
 
702
644
  # Step 7: Verify NSSM
703
- Write-Step 7 $totalSteps "Verifying NSSM..."
645
+ Write-Step 6 $totalSteps "Verifying NSSM..."
704
646
  Assert-NssmAvailable
705
647
  $nssmVersion = Invoke-Nssm version
706
648
  Write-Detail "NSSM available: $NssmPath ($nssmVersion)"
707
649
 
708
650
  # Step 8: Register Windows Services via NSSM
709
- Write-Step 8 $totalSteps "Registering Windows Services..."
651
+ Write-Step 7 $totalSteps "Registering Windows Services..."
710
652
 
711
653
  $serverJs = Join-Path $minionPkgDir 'win\server.js'
712
654
  if (-not (Test-Path $serverJs)) {
713
655
  Write-Error "server.js not found at $serverJs"
714
- Write-Host " Please run: npm install -g @geekbeer/minion"
656
+ Write-Host " Please run (admin PowerShell): npm install -g @geekbeer/minion"
715
657
  exit 1
716
658
  }
717
659
  $nodePath = (Get-Command node).Source
@@ -743,13 +685,15 @@ function Invoke-Setup {
743
685
  Invoke-Nssm set minion-agent Start SERVICE_AUTO_START
744
686
  Invoke-Nssm set minion-agent DisplayName "Minion Agent"
745
687
  Invoke-Nssm set minion-agent Description "GeekBeer Minion AI Agent Service"
746
- # Run as dedicated minion service account (not LocalSystem)
747
- Invoke-Nssm set minion-agent ObjectName $MinionSvcUserFull $svcPassword
688
+ # Runs as LocalSystem (NSSM default). USERPROFILE/HOME env vars point to target user's profile.
748
689
  Grant-ServiceControlToUser 'minion-agent' $setupUserSid
749
- Write-Detail "minion-agent service registered (runs as '$MinionSvcUser')"
690
+ if ($targetUserSid -and $targetUserSid -ne $setupUserSid) {
691
+ Grant-ServiceControlToUser 'minion-agent' $targetUserSid
692
+ }
693
+ Write-Detail "minion-agent service registered (runs as LocalSystem)"
750
694
 
751
- # Step 9: Install and configure TightVNC (runs as LocalSystem for desktop capture)
752
- Write-Step 9 $totalSteps "Setting up TightVNC Server..."
695
+ # Step 9: Install and configure TightVNC (runs as logon task in user session for desktop capture)
696
+ Write-Step 8 $totalSteps "Setting up TightVNC Server..."
753
697
  $vncSystemPath = 'C:\Program Files\TightVNC\tvnserver.exe'
754
698
  $vncPortableDir = Join-Path $DataDir 'tightvnc'
755
699
  $vncPortablePath = Join-Path $vncPortableDir 'PFiles\TightVNC\tvnserver.exe'
@@ -798,31 +742,32 @@ function Invoke-Setup {
798
742
  }
799
743
 
800
744
  # Configure TightVNC registry (localhost-only, no VNC auth)
801
- $vncRegPath = 'HKCU:\Software\TightVNC\Server'
745
+ # Write to both HKCU (for user-session -run mode) and HKLM (fallback)
802
746
  if ($vncExePath) {
803
- if (-not (Test-Path $vncRegPath)) {
804
- New-Item -Path $vncRegPath -Force | Out-Null
747
+ foreach ($vncRegPath in @('HKCU:\Software\TightVNC\Server', 'HKLM:\Software\TightVNC\Server')) {
748
+ if (-not (Test-Path $vncRegPath)) {
749
+ New-Item -Path $vncRegPath -Force | Out-Null
750
+ }
751
+ Set-ItemProperty -Path $vncRegPath -Name 'LoopbackOnly' -Value 1 -Type DWord
752
+ Set-ItemProperty -Path $vncRegPath -Name 'AllowLoopback' -Value 1 -Type DWord
753
+ Set-ItemProperty -Path $vncRegPath -Name 'UseVncAuthentication' -Value 0 -Type DWord
754
+ Set-ItemProperty -Path $vncRegPath -Name 'UseControlAuthentication' -Value 0 -Type DWord
755
+ Set-ItemProperty -Path $vncRegPath -Name 'RfbPort' -Value 5900 -Type DWord
805
756
  }
806
- Set-ItemProperty -Path $vncRegPath -Name 'LoopbackOnly' -Value 1 -Type DWord
807
- Set-ItemProperty -Path $vncRegPath -Name 'AllowLoopback' -Value 1 -Type DWord
808
- Set-ItemProperty -Path $vncRegPath -Name 'UseVncAuthentication' -Value 0 -Type DWord
809
- Set-ItemProperty -Path $vncRegPath -Name 'UseControlAuthentication' -Value 0 -Type DWord
810
- Set-ItemProperty -Path $vncRegPath -Name 'RfbPort' -Value 5900 -Type DWord
757
+ Write-Detail "TightVNC registry configured (HKCU + HKLM)"
811
758
 
812
- # Register VNC as NSSM service (application mode, not TightVNC's own service)
759
+ # Remove legacy NSSM service if present (VNC now runs as logon task)
813
760
  Invoke-Nssm stop minion-vnc
814
761
  Invoke-Nssm remove minion-vnc confirm
815
- Invoke-Nssm install minion-vnc $vncExePath '-run'
816
- Invoke-Nssm set minion-vnc Start SERVICE_AUTO_START
817
- Invoke-Nssm set minion-vnc DisplayName "Minion VNC Server"
818
- Invoke-Nssm set minion-vnc Description "TightVNC for Minion remote desktop"
819
- Invoke-Nssm set minion-vnc AppRestartDelay 3000
820
- Grant-ServiceControlToUser 'minion-vnc' $setupUserSid
821
- Write-Detail "minion-vnc service registered"
762
+
763
+ # Register VNC as logon task (must run in user session for desktop capture)
764
+ schtasks /Delete /TN "MinionVNC" /F 2>$null
765
+ schtasks /Create /TN "MinionVNC" /TR "'$vncExePath' -run" /SC ONLOGON /RL HIGHEST /F | Out-Null
766
+ Write-Detail "TightVNC registered as logon task (user session, not service)"
822
767
  }
823
768
 
824
769
  # Step 10: Setup websockify (runs as LocalSystem, paired with VNC)
825
- Write-Step 10 $totalSteps "Setting up websockify..."
770
+ Write-Step 9 $totalSteps "Setting up websockify..."
826
771
  [array]$wsCmd = Get-WebsockifyCommand
827
772
  if (-not $wsCmd) {
828
773
  # Ensure Python is installed
@@ -878,7 +823,7 @@ function Invoke-Setup {
878
823
  }
879
824
 
880
825
  if ($wsCmd -and $vncExePath) {
881
- # Register websockify as NSSM service
826
+ # Register websockify as NSSM service (no dependency on minion-vnc — VNC runs as logon task)
882
827
  Invoke-Nssm stop minion-websockify
883
828
  Invoke-Nssm remove minion-websockify confirm
884
829
  if ($wsCmd.Count -eq 1) {
@@ -888,19 +833,21 @@ function Invoke-Setup {
888
833
  $wsArgs = ($wsCmd[1..($wsCmd.Count-1)] + @('6080', 'localhost:5900')) -join ' '
889
834
  Invoke-Nssm install minion-websockify $wsCmd[0] $wsArgs
890
835
  }
891
- Invoke-Nssm set minion-websockify DependOnService minion-vnc
892
836
  Invoke-Nssm set minion-websockify Start SERVICE_AUTO_START
893
837
  Invoke-Nssm set minion-websockify DisplayName "Minion Websockify"
894
838
  Invoke-Nssm set minion-websockify Description "WebSocket proxy for VNC (6080 -> 5900)"
895
839
  Invoke-Nssm set minion-websockify AppRestartDelay 3000
896
840
  Grant-ServiceControlToUser 'minion-websockify' $setupUserSid
897
- Write-Detail "minion-websockify service registered (depends on minion-vnc)"
841
+ if ($targetUserSid -and $targetUserSid -ne $setupUserSid) {
842
+ Grant-ServiceControlToUser 'minion-websockify' $targetUserSid
843
+ }
844
+ Write-Detail "minion-websockify service registered"
898
845
  } else {
899
846
  Write-Warn "websockify not available, VNC WebSocket proxy will not be registered"
900
847
  }
901
848
 
902
849
  # Step 11: Disable screensaver, lock screen, and sleep
903
- Write-Step 11 $totalSteps "Disabling screensaver, lock screen, and sleep..."
850
+ Write-Step 10 $totalSteps "Disabling screensaver, lock screen, and sleep..."
904
851
  Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '0'
905
852
  Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '0'
906
853
  Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name SCRNSAVE.EXE -Value ''
@@ -914,7 +861,7 @@ function Invoke-Setup {
914
861
  Write-Detail "Sleep and monitor timeout disabled"
915
862
 
916
863
  # Configure firewall rules
917
- Write-Step 10 $totalSteps "Configuring firewall rules..."
864
+ Write-Step 11 $totalSteps "Configuring firewall rules..."
918
865
  $fwRules = @(
919
866
  @{ Name = 'Minion Agent'; Port = 8080 },
920
867
  @{ Name = 'Minion Terminal'; Port = 7681 },
@@ -930,25 +877,73 @@ function Invoke-Setup {
930
877
  }
931
878
  }
932
879
 
933
- # Grant minion service account access to .claude directory (skills, rules, settings)
934
- $claudeDir = Join-Path $TargetUserProfile '.claude'
935
- New-Item -Path $claudeDir -ItemType Directory -Force | Out-Null
936
- $claudeAcl = Get-Acl $claudeDir
937
- $claudeRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
938
- $MinionSvcUser, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
939
- $claudeAcl.AddAccessRule($claudeRule)
940
- Set-Acl $claudeDir $claudeAcl
941
- Write-Detail "Granted '$MinionSvcUser' access to $claudeDir"
880
+ # Grant target user access to admin's minion package and bin links (so target user can run minion-cli-win)
881
+ $adminNpmBin = Split-Path (Get-Command minion-cli-win -ErrorAction SilentlyContinue).Source -ErrorAction SilentlyContinue
882
+ if (-not $adminNpmBin) {
883
+ $adminNpmBin = & npm config get prefix 2>$null
884
+ }
885
+ if ($adminNpmBin -and ($adminNpmBin -ne (Join-Path $TargetUserProfile 'AppData\Roaming\npm'))) {
886
+ $targetUserName = Split-Path $TargetUserProfile -Leaf
942
887
 
943
- # Grant minion service account access to npm global directory (for package access)
944
- $npmGlobalDir = Split-Path $CliDir
945
- if ($npmGlobalDir -and (Test-Path $npmGlobalDir)) {
946
- $npmAcl = Get-Acl $npmGlobalDir
947
- $npmRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
948
- $MinionSvcUser, 'ReadAndExecute', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
949
- $npmAcl.AddAccessRule($npmRule)
950
- Set-Acl $npmGlobalDir $npmAcl
951
- Write-Detail "Granted '$MinionSvcUser' read access to npm global"
888
+ # Grant ReadAndExecute on bin links (minion-cli-win.cmd, hq-win.cmd, etc.)
889
+ $binFiles = Get-ChildItem -Path $adminNpmBin -Filter '*minion*' -ErrorAction SilentlyContinue
890
+ $binFiles += Get-ChildItem -Path $adminNpmBin -Filter '*hq-win*' -ErrorAction SilentlyContinue
891
+ foreach ($f in $binFiles) {
892
+ icacls $f.FullName /grant "${targetUserName}:(RX)" /Q 2>$null | Out-Null
893
+ }
894
+ # Grant ReadAndExecute on the @geekbeer/minion package directory (recursive)
895
+ $minionPkgDir = Join-Path (Join-Path $adminNpmBin 'node_modules') '@geekbeer\minion'
896
+ if (Test-Path $minionPkgDir) {
897
+ icacls $minionPkgDir /grant "${targetUserName}:(OI)(CI)RX" /T /Q 2>$null | Out-Null
898
+ Write-Detail "Granted target user read access to $minionPkgDir"
899
+ }
900
+ # Grant traverse access on ancestor directories so the path is reachable
901
+ # e.g., C:\Users\yunoda -> AppData -> Roaming -> npm -> node_modules -> @geekbeer
902
+ $traverseDirs = @(
903
+ $adminNpmBin,
904
+ (Join-Path $adminNpmBin 'node_modules'),
905
+ (Join-Path $adminNpmBin 'node_modules\@geekbeer')
906
+ )
907
+ # Walk up from npm bin dir to drive root to grant traverse (list+read) on each
908
+ $walkDir = $adminNpmBin
909
+ while ($walkDir) {
910
+ $parent = Split-Path $walkDir -Parent
911
+ if (-not $parent -or $parent -eq $walkDir) { break }
912
+ $traverseDirs += $parent
913
+ $walkDir = $parent
914
+ # Stop at drive root
915
+ if ($parent.Length -le 3) { break }
916
+ }
917
+ foreach ($dir in ($traverseDirs | Select-Object -Unique)) {
918
+ if (Test-Path $dir) {
919
+ # Grant only traverse + list (no recursive, no inherit)
920
+ icacls $dir /grant "${targetUserName}:(RX)" /Q 2>$null | Out-Null
921
+ }
922
+ }
923
+
924
+ # Add admin's npm bin to target user's PATH
925
+ $targetUserSid = $null
926
+ try {
927
+ $targetUserSid = (New-Object System.Security.Principal.NTAccount($targetUserName)).Translate(
928
+ [System.Security.Principal.SecurityIdentifier]).Value
929
+ } catch {}
930
+ if ($targetUserSid) {
931
+ $regPath = "Registry::HKEY_USERS\$targetUserSid\Environment"
932
+ if (Test-Path $regPath) {
933
+ $currentPath = (Get-ItemProperty -Path $regPath -Name PATH -ErrorAction SilentlyContinue).PATH
934
+ if ($currentPath -and $currentPath -notlike "*$adminNpmBin*") {
935
+ Set-ItemProperty -Path $regPath -Name PATH -Value "$currentPath;$adminNpmBin"
936
+ Write-Detail "Added $adminNpmBin to target user's PATH"
937
+ } elseif (-not $currentPath) {
938
+ Set-ItemProperty -Path $regPath -Name PATH -Value $adminNpmBin
939
+ Write-Detail "Set target user's PATH to $adminNpmBin"
940
+ } else {
941
+ Write-Detail "Target user's PATH already contains $adminNpmBin"
942
+ }
943
+ } else {
944
+ Write-Warn "Target user's registry not loaded. User must log in and re-run setup, or manually add $adminNpmBin to PATH."
945
+ }
946
+ }
952
947
  }
953
948
 
954
949
  Write-Host ""
@@ -958,8 +953,8 @@ function Invoke-Setup {
958
953
  Write-Host ""
959
954
  Write-Host "Services registered (not yet started):"
960
955
  Write-Host " minion-agent - AI Agent (port 8080)"
961
- Write-Host " minion-vnc - TightVNC Server (port 5900)"
962
956
  Write-Host " minion-websockify - WebSocket proxy (port 6080)"
957
+ Write-Host " MinionVNC (task) - TightVNC (port 5900, starts at logon)"
963
958
  Write-Host ""
964
959
  Write-Host "Next step: Connect to HQ (run as regular user):" -ForegroundColor Yellow
965
960
  Write-Host " minion-cli-win configure ``"
@@ -1001,12 +996,12 @@ function Invoke-Uninstall {
1001
996
  }
1002
997
  Write-Host ""
1003
998
 
1004
- $totalSteps = 7
999
+ $totalSteps = 6
1005
1000
 
1006
1001
  # Step 1: Stop and remove all NSSM services
1007
1002
  Write-Step 1 $totalSteps "Stopping and removing services..."
1008
1003
  if ($NssmPath -and (Test-Path $NssmPath)) {
1009
- foreach ($svc in @('minion-cloudflared', 'minion-websockify', 'minion-vnc', 'minion-agent')) {
1004
+ foreach ($svc in @('minion-cloudflared', 'minion-websockify', 'minion-agent')) {
1010
1005
  $status = Invoke-Nssm status $svc
1011
1006
  if ($status) {
1012
1007
  Invoke-Nssm stop $svc
@@ -1016,6 +1011,12 @@ function Invoke-Uninstall {
1016
1011
  }
1017
1012
  }
1018
1013
 
1014
+ # Remove VNC logon task and legacy NSSM service
1015
+ schtasks /Delete /TN "MinionVNC" /F 2>$null
1016
+ Invoke-Nssm stop minion-vnc
1017
+ Invoke-Nssm remove minion-vnc confirm
1018
+ Write-Detail "VNC logon task and legacy service removed"
1019
+
1019
1020
  # Also stop legacy processes
1020
1021
  Stop-Process -Name tvnserver -Force -ErrorAction SilentlyContinue
1021
1022
  Stop-Process -Name websockify -Force -ErrorAction SilentlyContinue
@@ -1024,7 +1025,7 @@ function Invoke-Uninstall {
1024
1025
 
1025
1026
  # Step 2: Remove firewall rules
1026
1027
  Write-Step 2 $totalSteps "Removing firewall rules..."
1027
- foreach ($ruleName in @('Minion Agent', 'Minion VNC')) {
1028
+ foreach ($ruleName in @('Minion Agent', 'Minion Terminal', 'Minion VNC')) {
1028
1029
  $existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
1029
1030
  if ($existing) {
1030
1031
  Remove-NetFirewallRule -DisplayName $ruleName
@@ -1095,24 +1096,15 @@ function Invoke-Uninstall {
1095
1096
  Write-Detail "Removed rules: core.md"
1096
1097
  }
1097
1098
 
1098
- # Step 6: Remove minion service account
1099
- Write-Step 6 $totalSteps "Removing service account..."
1100
- $MinionSvcUser = 'minion'
1101
- if (Get-LocalUser -Name $MinionSvcUser -ErrorAction SilentlyContinue) {
1102
- Remove-LocalUser -Name $MinionSvcUser
1103
- Write-Detail "Removed local user '$MinionSvcUser'"
1104
- } else {
1105
- Write-Detail "Service account '$MinionSvcUser' not found, skipping"
1106
- }
1107
- # Remove stored service password
1099
+ # Clean up legacy service account and password file (from v3.1.0-v3.4.x)
1108
1100
  $svcPasswordFile = Join-Path $DataDir '.svc-password'
1109
1101
  if (Test-Path $svcPasswordFile) {
1110
1102
  Remove-Item $svcPasswordFile -Force
1111
- Write-Detail "Removed service credentials file"
1103
+ Write-Detail "Removed legacy service credentials file"
1112
1104
  }
1113
1105
 
1114
- # Step 7: Remove Cloudflare Tunnel configuration
1115
- Write-Step 7 $totalSteps "Removing Cloudflare Tunnel configuration..."
1106
+ # Step 6: Remove Cloudflare Tunnel configuration
1107
+ Write-Step 6 $totalSteps "Removing Cloudflare Tunnel configuration..."
1116
1108
  $cfConfigDir = Join-Path $TargetUserProfile '.cloudflared'
1117
1109
  if (Test-Path $cfConfigDir) {
1118
1110
  Remove-Item $cfConfigDir -Recurse -Force
@@ -1280,13 +1272,24 @@ function Invoke-Configure {
1280
1272
  # Start services (uses sc.exe — works without admin via SDDL)
1281
1273
  $startStep = if ($SetupTunnel) { $totalSteps - 2 } else { $totalSteps - 2 }
1282
1274
  Write-Step ($totalSteps - 1) $totalSteps "Starting services..."
1283
- # Start VNC/websockify if registered
1284
- foreach ($svc in @('minion-vnc', 'minion-websockify')) {
1285
- $svcState = Get-ServiceState $svc
1286
- if ($svcState -and $svcState -ne 'RUNNING') {
1287
- sc.exe start $svc 2>&1 | Out-Null
1288
- Write-Detail "$svc started"
1275
+ # Start VNC (logon task — runs in user session for desktop capture)
1276
+ $vncProcess = Get-Process -Name tvnserver -ErrorAction SilentlyContinue
1277
+ if (-not $vncProcess) {
1278
+ $vncSystemPath = 'C:\Program Files\TightVNC\tvnserver.exe'
1279
+ $vncPortablePath = Join-Path $DataDir 'tightvnc\PFiles\TightVNC\tvnserver.exe'
1280
+ $vncExe = if (Test-Path $vncSystemPath) { $vncSystemPath } elseif (Test-Path $vncPortablePath) { $vncPortablePath } else { $null }
1281
+ if ($vncExe) {
1282
+ Start-Process -FilePath $vncExe -ArgumentList '-run'
1283
+ Write-Detail "TightVNC started (user session)"
1289
1284
  }
1285
+ } else {
1286
+ Write-Detail "TightVNC already running"
1287
+ }
1288
+ # Start websockify service
1289
+ $wsState = Get-ServiceState 'minion-websockify'
1290
+ if ($wsState -and $wsState -ne 'RUNNING') {
1291
+ sc.exe start minion-websockify 2>&1 | Out-Null
1292
+ Write-Detail "minion-websockify started"
1290
1293
  }
1291
1294
  Start-MinionService
1292
1295
 
@@ -1334,16 +1337,24 @@ function Invoke-Configure {
1334
1337
  # ============================================================
1335
1338
 
1336
1339
  function Show-Status {
1337
- $state = Get-ServiceState 'minion-agent'
1338
- if ($state) {
1339
- Write-Host "minion-agent: $state"
1340
- } else {
1341
- Write-Host "minion-agent: not installed"
1340
+ try {
1341
+ $response = Invoke-RestMethod -Uri "$AgentUrl/api/status" -TimeoutSec 5 -ErrorAction Stop
1342
+ # Pretty-print the JSON response
1343
+ $response | ConvertTo-Json -Depth 5 | Write-Host
1344
+ }
1345
+ catch {
1346
+ # API unreachable — fall back to service state
1347
+ $state = Get-ServiceState 'minion-agent'
1348
+ if ($state) {
1349
+ Write-Host "minion-agent: $state (API unreachable)"
1350
+ } else {
1351
+ Write-Host "minion-agent: not installed"
1352
+ }
1342
1353
  }
1343
1354
  }
1344
1355
 
1345
1356
  function Show-Daemons {
1346
- foreach ($svc in @('minion-agent', 'minion-vnc', 'minion-websockify', 'minion-cloudflared')) {
1357
+ foreach ($svc in @('minion-agent', 'minion-websockify', 'minion-cloudflared')) {
1347
1358
  $state = Get-ServiceState $svc
1348
1359
  if ($state) {
1349
1360
  Write-Host "${svc}: $state"
@@ -1351,6 +1362,13 @@ function Show-Daemons {
1351
1362
  Write-Host "${svc}: not installed"
1352
1363
  }
1353
1364
  }
1365
+ # VNC runs as logon task, not NSSM service
1366
+ $vncProc = Get-Process -Name tvnserver -ErrorAction SilentlyContinue
1367
+ if ($vncProc) {
1368
+ Write-Host "vnc (task): RUNNING (PID $($vncProc[0].Id))"
1369
+ } else {
1370
+ Write-Host "vnc (task): not running"
1371
+ }
1354
1372
  }
1355
1373
 
1356
1374
  function Show-Health {
@@ -1383,13 +1401,7 @@ function Show-Diagnose {
1383
1401
  Write-Host ""
1384
1402
 
1385
1403
  Write-Host "Service Account:" -ForegroundColor Yellow
1386
- $MinionSvcUser = 'minion'
1387
- $svcUser = Get-LocalUser -Name $MinionSvcUser -ErrorAction SilentlyContinue
1388
- if ($svcUser) {
1389
- Write-Host " User: $MinionSvcUser (Enabled: $($svcUser.Enabled))"
1390
- } else {
1391
- Write-Host " User: NOT FOUND (services run as LocalSystem)" -ForegroundColor Yellow
1392
- }
1404
+ Write-Host " Runs as: LocalSystem"
1393
1405
  Write-Host ""
1394
1406
 
1395
1407
  Write-Host "NSSM:" -ForegroundColor Yellow
@@ -0,0 +1,82 @@
1
+ # postinstall.ps1 — Re-apply file ACLs after npm install -g replaces the package directory.
2
+ # Called automatically from postinstall.js on Windows.
3
+ # Requires Administrator privileges (npm install -g typically runs as admin).
4
+ # Exit silently if not admin or if setup has never been run (no .target-user-profile).
5
+
6
+ $ErrorActionPreference = 'SilentlyContinue'
7
+
8
+ # --- Locate setup metadata ---------------------------------------------------
9
+ # During setup, .minion/.target-user-profile is written to the target user's
10
+ # home directory. We need to find it by scanning user profiles.
11
+
12
+ function Find-TargetUserProfile {
13
+ $profiles = Get-CimInstance Win32_UserProfile -ErrorAction SilentlyContinue |
14
+ Where-Object { -not $_.Special -and $_.LocalPath -and $_.LocalPath -notmatch '\\(systemprofile|LocalService|NetworkService)$' }
15
+
16
+ foreach ($p in $profiles) {
17
+ $candidate = Join-Path $p.LocalPath '.minion\.target-user-profile'
18
+ if (Test-Path $candidate) {
19
+ $saved = ([System.IO.File]::ReadAllText($candidate)).Trim()
20
+ if ($saved -and (Test-Path $saved)) { return $saved }
21
+ }
22
+ }
23
+ return $null
24
+ }
25
+
26
+ # --- Main ---------------------------------------------------------------------
27
+
28
+ # Must be admin (npm install -g runs as admin)
29
+ $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
30
+ [Security.Principal.WindowsBuiltInRole]::Administrator)
31
+ if (-not $isAdmin) { exit 0 }
32
+
33
+ $TargetUserProfile = Find-TargetUserProfile
34
+ if (-not $TargetUserProfile) { exit 0 } # setup never ran — nothing to fix
35
+
36
+ $targetUserName = Split-Path $TargetUserProfile -Leaf
37
+
38
+ # Determine npm global bin directory (where minion-cli-win.cmd lives)
39
+ $adminNpmBin = Split-Path (Get-Command minion-cli-win -ErrorAction SilentlyContinue).Source -ErrorAction SilentlyContinue
40
+ if (-not $adminNpmBin) {
41
+ $adminNpmBin = & npm config get prefix 2>$null
42
+ }
43
+ if (-not $adminNpmBin) { exit 0 }
44
+
45
+ # Skip if the target user's own npm prefix (no ACL fix needed)
46
+ if ($adminNpmBin -eq (Join-Path $TargetUserProfile 'AppData\Roaming\npm')) { exit 0 }
47
+
48
+ # 1. Grant ReadAndExecute on bin links (minion-cli-win.cmd, hq-win.cmd, etc.)
49
+ $binFiles = @()
50
+ $binFiles += Get-ChildItem -Path $adminNpmBin -Filter '*minion*' -ErrorAction SilentlyContinue
51
+ $binFiles += Get-ChildItem -Path $adminNpmBin -Filter '*hq-win*' -ErrorAction SilentlyContinue
52
+ foreach ($f in $binFiles) {
53
+ icacls $f.FullName /grant "${targetUserName}:(RX)" /Q 2>$null | Out-Null
54
+ }
55
+
56
+ # 2. Grant ReadAndExecute on the @geekbeer/minion package directory (recursive)
57
+ $minionPkgDir = Join-Path (Join-Path $adminNpmBin 'node_modules') '@geekbeer\minion'
58
+ if (Test-Path $minionPkgDir) {
59
+ icacls $minionPkgDir /grant "${targetUserName}:(OI)(CI)RX" /T /Q 2>$null | Out-Null
60
+ }
61
+
62
+ # 3. Grant traverse access on ancestor directories (idempotent)
63
+ $traverseDirs = @(
64
+ $adminNpmBin,
65
+ (Join-Path $adminNpmBin 'node_modules'),
66
+ (Join-Path $adminNpmBin 'node_modules\@geekbeer')
67
+ )
68
+ $walkDir = $adminNpmBin
69
+ while ($walkDir) {
70
+ $parent = Split-Path $walkDir -Parent
71
+ if (-not $parent -or $parent -eq $walkDir) { break }
72
+ $traverseDirs += $parent
73
+ $walkDir = $parent
74
+ if ($parent.Length -le 3) { break }
75
+ }
76
+ foreach ($dir in ($traverseDirs | Select-Object -Unique)) {
77
+ if (Test-Path $dir) {
78
+ icacls $dir /grant "${targetUserName}:(RX)" /Q 2>$null | Out-Null
79
+ }
80
+ }
81
+
82
+ Write-Host "[@geekbeer/minion] postinstall: file permissions restored for user '$targetUserName'"
@@ -4,7 +4,7 @@
4
4
  * Same API as routes/commands.js but uses win/process-manager.js
5
5
  */
6
6
 
7
- const { exec, spawn } = require('child_process')
7
+ const { exec, execSync } = require('child_process')
8
8
  const { promisify } = require('util')
9
9
  const fs = require('fs')
10
10
  const path = require('path')
@@ -13,11 +13,11 @@ const execAsync = promisify(exec)
13
13
 
14
14
  const { verifyToken } = require('../../core/lib/auth')
15
15
  const { config } = require('../../core/config')
16
- const { detectProcessManager, buildAllowedCommands } = require('../lib/process-manager')
16
+ const { detectProcessManager, buildAllowedCommands, getNssmPath } = require('../lib/process-manager')
17
17
 
18
- const SPAWN_LOG = path.join(os.homedir(), '.minion', 'spawn-debug.log')
19
- function spawnLog(msg) {
20
- try { fs.appendFileSync(SPAWN_LOG, `${new Date().toISOString()} ${msg}\n`) } catch {}
18
+ const DEFERRED_LOG = path.join(os.homedir(), '.minion', 'logs', 'deferred-debug.log')
19
+ function deferredLog(msg) {
20
+ try { fs.appendFileSync(DEFERRED_LOG, `${new Date().toISOString()} ${msg}\n`) } catch {}
21
21
  }
22
22
 
23
23
  const PROC_MGR = detectProcessManager()
@@ -64,29 +64,45 @@ async function commandRoutes(fastify) {
64
64
  if (allowedCommand.deferred) {
65
65
  console.log(`[Command] Scheduling deferred command: ${command}`)
66
66
  setTimeout(() => {
67
- if (allowedCommand.spawnArgs) {
68
- const [cmd, args] = allowedCommand.spawnArgs
69
- spawnLog(`[${command}] spawn: ${cmd} ${JSON.stringify(args)}`)
67
+ if (allowedCommand.nssmService) {
68
+ // Register and start a temporary NSSM service to run the script
69
+ // independently of the minion-agent service process tree.
70
+ const { scriptPath } = allowedCommand.nssmService
71
+ const nssmPath = getNssmPath()
72
+ const svcName = 'minion-update'
73
+ deferredLog(`[${command}] registering NSSM service: ${svcName}`)
70
74
  try {
71
- const stderrPath = path.join(os.homedir(), '.minion', `${command}-stderr.log`)
72
- const stderrFd = fs.openSync(stderrPath, 'w')
73
- const child = spawn(cmd, args, {
74
- detached: true,
75
- stdio: ['ignore', 'ignore', stderrFd],
76
- })
77
- child.on('error', (err) => {
78
- spawnLog(`[${command}] child error: ${err.message}`)
79
- console.error(`[Command] Spawn child error: ${command} - ${err.message}`)
80
- })
81
- child.on('exit', (code, signal) => {
82
- spawnLog(`[${command}] child exit: code=${code} signal=${signal}`)
83
- })
84
- child.unref()
85
- spawnLog(`[${command}] spawned pid=${child.pid}`)
86
- console.log(`[Command] Deferred command spawned: ${command} (pid: ${child.pid})`)
75
+ // Copy nssm.exe to data dir so the service wrapper binary is
76
+ // outside the npm package directory. This prevents EBUSY when
77
+ // npm tries to replace the package during update.
78
+ const dataDirNssm = path.join(os.homedir(), '.minion', 'nssm.exe')
79
+ fs.copyFileSync(nssmPath, dataDirNssm)
80
+ const svcNssm = dataDirNssm
81
+
82
+ // Clean up any leftover service from a previous run
83
+ try { execSync(`"${svcNssm}" stop ${svcName} confirm`, { stdio: 'ignore', timeout: 10000 }) } catch {}
84
+ try { execSync(`"${svcNssm}" remove ${svcName} confirm`, { stdio: 'ignore', timeout: 10000 }) } catch {}
85
+
86
+ const powershellPath = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
87
+ const args = `-NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"`
88
+
89
+ execSync(`"${svcNssm}" install ${svcName} "${powershellPath}" ${args}`, { timeout: 10000 })
90
+ execSync(`"${svcNssm}" set ${svcName} AppDirectory "${path.dirname(scriptPath)}"`, { stdio: 'ignore', timeout: 5000 })
91
+ execSync(`"${svcNssm}" set ${svcName} Start SERVICE_DEMAND_START`, { stdio: 'ignore', timeout: 5000 })
92
+ execSync(`"${svcNssm}" set ${svcName} AppExit Default Exit`, { stdio: 'ignore', timeout: 5000 })
93
+
94
+ // Redirect service stdout/stderr to log files
95
+ const svcLogDir = path.join(os.homedir(), '.minion', 'logs')
96
+ execSync(`"${svcNssm}" set ${svcName} AppStdout "${path.join(svcLogDir, `${command}-svc-stdout.log`)}"`, { stdio: 'ignore', timeout: 5000 })
97
+ execSync(`"${svcNssm}" set ${svcName} AppStderr "${path.join(svcLogDir, `${command}-svc-stderr.log`)}"`, { stdio: 'ignore', timeout: 5000 })
98
+
99
+ // Start the service (runs asynchronously, independent of minion-agent)
100
+ execSync(`"${svcNssm}" start ${svcName}`, { timeout: 10000 })
101
+ deferredLog(`[${command}] NSSM service started: ${svcName}`)
102
+ console.log(`[Command] Deferred command started via NSSM service: ${command}`)
87
103
  } catch (err) {
88
- spawnLog(`[${command}] spawn threw: ${err.message}`)
89
- console.error(`[Command] Deferred spawn failed: ${command} - ${err.message}`)
104
+ deferredLog(`[${command}] NSSM service failed: ${err.message}`)
105
+ console.error(`[Command] Deferred NSSM service failed: ${command} - ${err.message}`)
90
106
  }
91
107
  } else {
92
108
  exec(allowedCommand.command, { timeout: 60000, shell: true }, (err) => {