@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 +4 -2
- package/postinstall.js +18 -0
- package/win/lib/process-manager.js +92 -25
- package/win/minion-cli.ps1 +185 -173
- package/win/postinstall.ps1 +82 -0
- package/win/routes/commands.js +42 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekbeer/minion",
|
|
3
|
-
"version": "3.5.
|
|
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
|
|
86
|
-
const
|
|
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
|
-
|
|
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
|
-
` &
|
|
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
|
-
` $
|
|
103
|
-
`
|
|
104
|
-
`
|
|
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
|
-
` &
|
|
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
|
-
` &
|
|
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(
|
|
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
|
|
135
|
-
const
|
|
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
|
-
`$
|
|
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
|
-
` &
|
|
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
|
-
` &
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
package/win/minion-cli.ps1
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
575
|
-
Write-Step 4 $totalSteps "Creating
|
|
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
|
-
#
|
|
657
|
-
$
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
752
|
-
Write-Step
|
|
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
|
-
|
|
745
|
+
# Write to both HKCU (for user-session -run mode) and HKLM (fallback)
|
|
802
746
|
if ($vncExePath) {
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
934
|
-
$
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
$
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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 =
|
|
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-
|
|
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
|
-
#
|
|
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
|
|
1115
|
-
Write-Step
|
|
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
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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-
|
|
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
|
-
|
|
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'"
|
package/win/routes/commands.js
CHANGED
|
@@ -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,
|
|
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
|
|
19
|
-
function
|
|
20
|
-
try { fs.appendFileSync(
|
|
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.
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
console.error(`[Command] Deferred
|
|
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) => {
|