From 7b622fb2fd3aeae1604d0c54159e3592103a4a5b Mon Sep 17 00:00:00 2001 From: mpuckett Date: Thu, 25 Jun 2026 12:57:43 -0500 Subject: [PATCH] Initial --- server/models/settingsModel.js | 6 ++++ server/services/powershellRunner.js | 43 ++++++++++++++++++++++++----- server/test/settings.test.mjs | 16 ++++++++++- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/server/models/settingsModel.js b/server/models/settingsModel.js index 76d2de2..41999e8 100644 --- a/server/models/settingsModel.js +++ b/server/models/settingsModel.js @@ -12,6 +12,12 @@ export function getBooleanSetting(key, fallback) { return String(value).toLowerCase() === 'true'; } +export function getStringSetting(key, fallback = '') { + const value = getSetting(key); + const normalized = String(value ?? '').trim(); + return normalized || fallback; +} + // Effective CORS allow-list: admin-editable `trusted_origins` merged with the // built-in localhost defaults so the dev experience never locks itself out. export function effectiveTrustedOrigins() { diff --git a/server/services/powershellRunner.js b/server/services/powershellRunner.js index 4510adc..62d6942 100644 --- a/server/services/powershellRunner.js +++ b/server/services/powershellRunner.js @@ -7,7 +7,7 @@ import { config } from '../config.js'; import { logger } from './logger.js'; import { listExecutableAssets } from '../models/assetModel.js'; import { listExecutableCustomVariables } from '../models/variableModel.js'; -import { getBooleanSetting } from '../models/settingsModel.js'; +import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js'; import { path } from '../utils/pathUtils.js'; // The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and @@ -17,6 +17,25 @@ function scriptExecutionAllowed() { return getBooleanSetting('allow_script_execution', config.allowScriptExecution); } +export function resolvePowerShellExecutable() { + return getStringSetting('powershell_bin', config.powershellBin).trim(); +} + +export function powerShellSpawnFailureLines(error, executable) { + const code = error?.code ? ` (${error.code})` : ''; + const lines = [ + `Unable to start configured PowerShell executable "${executable}"${code}.`, + `Node spawn error: ${error?.message || 'unknown process start failure'}` + ]; + if (error?.code === 'ENOENT') { + lines.push('The executable was not found on the API server PATH.'); + lines.push('Windows: set Config > PowerShell Binary to "powershell.exe" or the full path "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".'); + lines.push('PowerShell 7: set it to "pwsh.exe" or the full pwsh install path.'); + } + lines.push(`API process platform: ${process.platform}; PATH: ${process.env.PATH || '(empty)'}`); + return lines; +} + export function executeRunPlan(runplanId, userId) { // Create the durable job record before async execution so the UI can attach to logs immediately. const runplan = db.prepare('SELECT * FROM runplans WHERE id = ?').get(runplanId); @@ -214,8 +233,11 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) { // Secrets are passed through process environment variables and are never written to job logs. const runtimeEnv = Object.fromEntries(runtimeVariableValues(host, executionContext).map((variable) => [variable.name, String(variable.value ?? '')])); const assetManifest = JSON.stringify(executionContext.assets || []); + const executable = resolvePowerShellExecutable(); + const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file]; return new Promise((resolve) => { - const child = spawn(config.powershellBin, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], { + appendLog(jobId, host.id, 'system', `Launching PowerShell executable "${executable}"`); + const child = spawn(executable, args, { env: { ...process.env, ...runtimeEnv, @@ -229,19 +251,26 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) { }, shell: false }); + let settled = false; + + function settle(status, exitCode) { + if (settled) return; + settled = true; + finishHost(jobId, host.id, status, exitCode); + resolve(); + } child.stdout.on('data', (chunk) => appendChunk(jobId, host.id, 'stdout', chunk)); child.stderr.on('data', (chunk) => appendChunk(jobId, host.id, 'stderr', chunk)); child.on('error', (error) => { - appendLog(jobId, host.id, 'stderr', `Unable to start PowerShell: ${error.message}`); - finishHost(jobId, host.id, 'failed', 127); - resolve(); + for (const line of powerShellSpawnFailureLines(error, executable)) appendLog(jobId, host.id, 'stderr', line); + settle('failed', 127); }); child.on('close', (code, signal) => { + if (settled) return; if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`); const exitCode = code ?? 1; - finishHost(jobId, host.id, exitCode === 0 ? 'completed' : 'failed', exitCode); - resolve(); + settle(exitCode === 0 ? 'completed' : 'failed', exitCode); }); }); } diff --git a/server/test/settings.test.mjs b/server/test/settings.test.mjs index 7df8f66..ad33b67 100644 --- a/server/test/settings.test.mjs +++ b/server/test/settings.test.mjs @@ -2,7 +2,8 @@ import { db, load } from './setup.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -const { getSetting, getBooleanSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.js'); +const { getSetting, getBooleanSetting, getStringSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.js'); +const { resolvePowerShellExecutable, powerShellSpawnFailureLines } = await load('../services/powershellRunner.js'); test('unpinned settings seed as editable defaults', () => { // No SERVER_FQDN env var is set in the test environment, so server_fqdn is a @@ -30,6 +31,19 @@ test('allow_script_execution is readable as an effective boolean', () => { assert.equal(getBooleanSetting('allow_script_execution', false), true); }); +test('powershell_bin config is used by the runner at execution time', () => { + updateSettings({ powershell_bin: 'powershell.exe' }); + assert.equal(getStringSetting('powershell_bin', 'pwsh'), 'powershell.exe'); + assert.equal(resolvePowerShellExecutable(), 'powershell.exe'); +}); + +test('PowerShell spawn failures include operator guidance', () => { + const lines = powerShellSpawnFailureLines({ code: 'ENOENT', message: 'spawn pwsh ENOENT' }, 'pwsh'); + assert.ok(lines.some((line) => line.includes('configured PowerShell executable "pwsh"'))); + assert.ok(lines.some((line) => line.includes('Config > PowerShell Binary'))); + assert.ok(lines.some((line) => line.includes('powershell.exe'))); +}); + test('effectiveTrustedOrigins merges admin edits with built-in defaults', () => { updateSettings({ trusted_origins: 'https://ops.example.com' }); const origins = effectiveTrustedOrigins();