From 9b239175c77f0a6e0019d028e6f36f2cee27510c Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 3 Oct 2025 17:39:08 -0600 Subject: [PATCH 1/8] Expose assembly variables for jobs --- .../WebUI/src/Scheduling/Create_Job.jsx | 435 +++++++++++++++--- .../Server/WebUI/src/Scheduling/Quick_Job.jsx | 221 ++++++++- Data/Server/job_scheduler.py | 84 +++- Data/Server/server.py | 52 ++- 4 files changed, 713 insertions(+), 79 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx index ad0ad9f..92893d9 100644 --- a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx +++ b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx @@ -124,7 +124,126 @@ function buildWorkflowTree(workflows, folders) { return { root: [rootNode], map }; } -function ComponentCard({ comp, onRemove }) { +function normalizeVariableDefinitions(vars = []) { + return (Array.isArray(vars) ? vars : []) + .map((raw) => { + if (!raw || typeof raw !== "object") return null; + const name = typeof raw.name === "string" ? raw.name.trim() : typeof raw.key === "string" ? raw.key.trim() : ""; + if (!name) return null; + const label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name; + const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string"; + const required = Boolean(raw.required); + const description = typeof raw.description === "string" ? raw.description : ""; + let defaultValue = ""; + if (Object.prototype.hasOwnProperty.call(raw, "default")) defaultValue = raw.default; + else if (Object.prototype.hasOwnProperty.call(raw, "defaultValue")) defaultValue = raw.defaultValue; + else if (Object.prototype.hasOwnProperty.call(raw, "default_value")) defaultValue = raw.default_value; + return { name, label, type, required, description, default: defaultValue }; + }) + .filter(Boolean); +} + +function coerceVariableValue(type, value) { + if (type === "boolean") { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (value == null) return false; + const str = String(value).trim().toLowerCase(); + if (!str) return false; + return ["true", "1", "yes", "on"].includes(str); + } + if (type === "number") { + if (value == null || value === "") return ""; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + const parsed = Number(value); + return Number.isFinite(parsed) ? String(parsed) : ""; + } + return value == null ? "" : String(value); +} + +function mergeComponentVariables(docVars = [], storedVars = [], storedValueMap = {}) { + const definitions = normalizeVariableDefinitions(docVars); + const overrides = {}; + const storedMeta = {}; + (Array.isArray(storedVars) ? storedVars : []).forEach((raw) => { + if (!raw || typeof raw !== "object") return; + const name = typeof raw.name === "string" ? raw.name.trim() : ""; + if (!name) return; + if (Object.prototype.hasOwnProperty.call(raw, "value")) overrides[name] = raw.value; + else if (Object.prototype.hasOwnProperty.call(raw, "default")) overrides[name] = raw.default; + storedMeta[name] = { + label: typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name, + type: typeof raw.type === "string" ? raw.type.toLowerCase() : undefined, + required: Boolean(raw.required), + description: typeof raw.description === "string" ? raw.description : "", + default: Object.prototype.hasOwnProperty.call(raw, "default") ? raw.default : "" + }; + }); + if (storedValueMap && typeof storedValueMap === "object") { + Object.entries(storedValueMap).forEach(([key, val]) => { + const name = typeof key === "string" ? key.trim() : ""; + if (name) overrides[name] = val; + }); + } + + const used = new Set(); + const merged = definitions.map((def) => { + const override = Object.prototype.hasOwnProperty.call(overrides, def.name) ? overrides[def.name] : undefined; + used.add(def.name); + return { + ...def, + value: override !== undefined ? coerceVariableValue(def.type, override) : coerceVariableValue(def.type, def.default) + }; + }); + + (Array.isArray(storedVars) ? storedVars : []).forEach((raw) => { + if (!raw || typeof raw !== "object") return; + const name = typeof raw.name === "string" ? raw.name.trim() : ""; + if (!name || used.has(name)) return; + const meta = storedMeta[name] || {}; + const type = meta.type || (typeof overrides[name] === "boolean" ? "boolean" : typeof overrides[name] === "number" ? "number" : "string"); + const defaultValue = Object.prototype.hasOwnProperty.call(meta, "default") ? meta.default : ""; + const override = Object.prototype.hasOwnProperty.call(overrides, name) + ? overrides[name] + : Object.prototype.hasOwnProperty.call(raw, "value") + ? raw.value + : defaultValue; + merged.push({ + name, + label: meta.label || name, + type, + required: Boolean(meta.required), + description: meta.description || "", + default: defaultValue, + value: coerceVariableValue(type, override) + }); + used.add(name); + }); + + Object.entries(overrides).forEach(([nameRaw, val]) => { + const name = typeof nameRaw === "string" ? nameRaw.trim() : ""; + if (!name || used.has(name)) return; + const type = typeof val === "boolean" ? "boolean" : typeof val === "number" ? "number" : "string"; + merged.push({ + name, + label: name, + type, + required: false, + description: "", + default: "", + value: coerceVariableValue(type, val) + }); + used.add(name); + }); + + return merged; +} + +function ComponentCard({ comp, onRemove, onVariableChange, errors = {} }) { + const variables = Array.isArray(comp.variables) + ? comp.variables.filter((v) => v && typeof v.name === "string" && v.name) + : []; + const description = comp.description || comp.path || ""; return ( @@ -133,30 +252,65 @@ function ComponentCard({ comp, onRemove }) { {comp.type === "script" ? comp.name : comp.name} - {comp.description || (comp.type === "script" ? comp.path : comp.path)} + {description} - Variables (placeholder) - } label={Example toggle} /> - - - - + Variables + {variables.length ? ( + + {variables.map((variable) => ( + + {variable.type === "boolean" ? ( + <> + onVariableChange(comp.localId, variable.name, e.target.checked)} + /> + )} + label={ + + {variable.label} + {variable.required ? " *" : ""} + + } + /> + {variable.description ? ( + + {variable.description} + + ) : null} + + ) : ( + onVariableChange(comp.localId, variable.name, e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ + "& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" }, + "& .MuiInputBase-input": { color: "#e6edf3" } + }} + error={Boolean(errors[variable.name])} + helperText={errors[variable.name] || variable.description || ""} + /> + )} + + ))} + + ) : ( + No variables defined for this assembly. + )} - + onRemove(comp.localId)} size="small" sx={{ color: "#ff6666" }}> @@ -189,6 +343,134 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { const [availableDevices, setAvailableDevices] = useState([]); // [{hostname, display, online}] const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool const [deviceSearch, setDeviceSearch] = useState(""); + const [componentVarErrors, setComponentVarErrors] = useState({}); + + const generateLocalId = useCallback( + () => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + [] + ); + + const normalizeComponentPath = useCallback((type, rawPath) => { + const trimmed = (rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim(); + if (!trimmed) return ""; + if (type === "script") { + return trimmed.startsWith("Scripts/") ? trimmed : `Scripts/${trimmed}`; + } + return trimmed; + }, []); + + const fetchAssemblyDoc = useCallback(async (type, rawPath) => { + const normalizedPath = normalizeComponentPath(type, rawPath); + if (!normalizedPath) return { doc: null, normalizedPath: "" }; + try { + const island = type === "ansible" ? "ansible" : "scripts"; + const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(normalizedPath)}`); + if (!resp.ok) { + return { doc: null, normalizedPath }; + } + const data = await resp.json(); + return { doc: data, normalizedPath }; + } catch { + return { doc: null, normalizedPath }; + } + }, [normalizeComponentPath]); + + const hydrateExistingComponents = useCallback(async (rawComponents = []) => { + const results = []; + for (const raw of rawComponents) { + if (!raw || typeof raw !== "object") continue; + const typeRaw = raw.type || raw.component_type || "script"; + if (typeRaw === "workflow") { + results.push({ + ...raw, + type: "workflow", + variables: Array.isArray(raw.variables) ? raw.variables : [], + localId: generateLocalId() + }); + continue; + } + const type = typeRaw === "ansible" ? "ansible" : "script"; + const basePath = raw.path || raw.script_path || raw.rel_path || ""; + const { doc, normalizedPath } = await fetchAssemblyDoc(type, basePath); + const assembly = doc?.assembly || {}; + const docVars = assembly?.variables || doc?.variables || []; + const mergedVariables = mergeComponentVariables(docVars, raw.variables, raw.variable_values); + results.push({ + ...raw, + type, + path: normalizedPath || basePath, + name: raw.name || assembly?.name || raw.file_name || raw.tab_name || normalizedPath || basePath, + description: raw.description || assembly?.description || normalizedPath || basePath, + variables: mergedVariables, + localId: generateLocalId() + }); + } + return results; + }, [fetchAssemblyDoc, generateLocalId]); + + const sanitizeComponentsForSave = useCallback((items) => { + return (Array.isArray(items) ? items : []).map((comp) => { + if (!comp || typeof comp !== "object") return comp; + const { localId, ...rest } = comp; + const sanitized = { ...rest }; + if (Array.isArray(comp.variables)) { + const valuesMap = {}; + sanitized.variables = comp.variables + .filter((v) => v && typeof v.name === "string" && v.name) + .map((v) => { + const entry = { + name: v.name, + label: v.label || v.name, + type: v.type || "string", + required: Boolean(v.required), + description: v.description || "" + }; + if (Object.prototype.hasOwnProperty.call(v, "default")) entry.default = v.default; + if (Object.prototype.hasOwnProperty.call(v, "value")) { + entry.value = v.value; + valuesMap[v.name] = v.value; + } + return entry; + }); + if (!sanitized.variables.length) sanitized.variables = []; + if (Object.keys(valuesMap).length) sanitized.variable_values = valuesMap; + else delete sanitized.variable_values; + } + return sanitized; + }); + }, []); + + const updateComponentVariable = useCallback((localId, name, value) => { + if (!localId || !name) return; + setComponents((prev) => prev.map((comp) => { + if (!comp || comp.localId !== localId) return comp; + const vars = Array.isArray(comp.variables) ? comp.variables : []; + const nextVars = vars.map((variable) => { + if (!variable || variable.name !== name) return variable; + return { ...variable, value: coerceVariableValue(variable.type || "string", value) }; + }); + return { ...comp, variables: nextVars }; + })); + setComponentVarErrors((prev) => { + if (!prev[localId] || !prev[localId][name]) return prev; + const next = { ...prev }; + const compErrors = { ...next[localId] }; + delete compErrors[name]; + if (Object.keys(compErrors).length) next[localId] = compErrors; + else delete next[localId]; + return next; + }); + }, []); + + const removeComponent = useCallback((localId) => { + setComponents((prev) => prev.filter((comp) => comp.localId !== localId)); + setComponentVarErrors((prev) => { + if (!prev[localId]) return prev; + const next = { ...prev }; + delete next[localId]; + return next; + }); + }, []); const isValid = useMemo(() => { const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0; @@ -355,18 +637,32 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { const deviceSorted = useMemo(() => deviceRows, [deviceRows]); useEffect(() => { - if (initialJob && initialJob.id) { - setJobName(initialJob.name || ""); - const comps = Array.isArray(initialJob.components) ? initialJob.components : []; - setComponents(comps.length ? comps : []); - setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []); - setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately"); - setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0))); - setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled)); - setExpiration(initialJob.expiration || "no_expire"); - setExecContext(initialJob.execution_context || "system"); - } - }, [initialJob]); + let canceled = false; + const hydrate = async () => { + if (initialJob && initialJob.id) { + setJobName(initialJob.name || ""); + setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []); + setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately"); + setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0))); + setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled)); + setExpiration(initialJob.expiration || "no_expire"); + setExecContext(initialJob.execution_context || "system"); + const comps = Array.isArray(initialJob.components) ? initialJob.components : []; + const hydrated = await hydrateExistingComponents(comps); + if (!canceled) { + setComponents(hydrated); + setComponentVarErrors({}); + } + } else if (!initialJob) { + setComponents([]); + setComponentVarErrors({}); + } + }; + hydrate(); + return () => { + canceled = true; + }; + }, [initialJob, hydrateExistingComponents]); const openAddComponent = async () => { setAddCompOpen(true); @@ -399,32 +695,38 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { } catch { setAnsibleTree([]); setAnsibleMap({}); } }; - const addSelectedComponent = () => { + const addSelectedComponent = useCallback(async () => { const map = compTab === "scripts" ? scriptMap : (compTab === "ansible" ? ansibleMap : workflowMap); const node = map[selectedNodeId]; if (!node || node.isFolder) return false; - if (compTab === "scripts" && node.script) { - setComponents((prev) => [ - ...prev, - // Store path relative to Assemblies root with 'Scripts/' prefix for scheduler compatibility - { type: "script", path: (node.path.startsWith('Scripts/') ? node.path : `Scripts/${node.path}`), name: node.fileName || node.label, description: node.path } - ]); - setSelectedNodeId(""); - return true; - } else if (compTab === "ansible" && node.script) { - setComponents((prev) => [ - ...prev, - { type: "ansible", path: node.path, name: node.fileName || node.label, description: node.path } - ]); - setSelectedNodeId(""); - return true; - } else if (compTab === "workflows" && node.workflow) { + if (compTab === "workflows" && node.workflow) { alert("Workflows within Scheduled Jobs are not supported yet"); return false; } + if (compTab === "scripts" || compTab === "ansible") { + const type = compTab === "scripts" ? "script" : "ansible"; + const rawPath = node.path || node.id || ""; + const { doc, normalizedPath } = await fetchAssemblyDoc(type, rawPath); + const assembly = doc?.assembly || {}; + const docVars = assembly?.variables || doc?.variables || []; + const mergedVars = mergeComponentVariables(docVars, [], {}); + setComponents((prev) => [ + ...prev, + { + type, + path: normalizedPath || rawPath, + name: assembly?.name || node.fileName || node.label, + description: assembly?.description || normalizedPath || rawPath, + variables: mergedVars, + localId: generateLocalId() + } + ]); + setSelectedNodeId(""); + return true; + } setSelectedNodeId(""); return false; - }; + }, [compTab, scriptMap, ansibleMap, workflowMap, selectedNodeId, fetchAssemblyDoc, generateLocalId]); const openAddTargets = async () => { setAddTargetOpen(true); @@ -449,9 +751,30 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { }; const handleCreate = async () => { + const requiredErrors = {}; + components.forEach((comp) => { + if (!comp || !comp.localId) return; + (Array.isArray(comp.variables) ? comp.variables : []).forEach((variable) => { + if (!variable || !variable.name || !variable.required) return; + if ((variable.type || "string") === "boolean") return; + const value = variable.value; + if (value == null || value === "") { + if (!requiredErrors[comp.localId]) requiredErrors[comp.localId] = {}; + requiredErrors[comp.localId][variable.name] = "Required"; + } + }); + }); + if (Object.keys(requiredErrors).length) { + setComponentVarErrors(requiredErrors); + setTab(1); + alert("Please fill in all required variable values."); + return; + } + setComponentVarErrors({}); + const payloadComponents = sanitizeComponentsForSave(components); const payload = { name: jobName, - components, + components: payloadComponents, targets, schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null }, duration: { stopAfterEnabled, expiration }, @@ -553,9 +876,13 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { {components.length === 0 && ( No assemblies added yet. )} - {components.map((c, idx) => ( - setComponents((prev) => prev.filter((_, i) => i !== idx))} + {components.map((c) => ( + ))} {components.length === 0 && ( @@ -820,7 +1147,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { - diff --git a/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx index 0aea259..9257ec8 100644 --- a/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx +++ b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx @@ -9,7 +9,8 @@ import { Typography, Paper, FormControlLabel, - Checkbox + Checkbox, + TextField } from "@mui/material"; import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material"; import { SimpleTreeView, TreeItem } from "@mui/x-tree-view"; @@ -81,6 +82,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { const [error, setError] = useState(""); const [runAsCurrentUser, setRunAsCurrentUser] = useState(false); const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible' + const [variables, setVariables] = useState([]); + const [variableValues, setVariableValues] = useState({}); + const [variableErrors, setVariableErrors] = useState({}); + const [variableStatus, setVariableStatus] = useState({ loading: false, error: "" }); const loadTree = useCallback(async () => { try { @@ -102,6 +107,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { if (open) { setSelectedPath(""); setError(""); + setVariables([]); + setVariableValues({}); + setVariableErrors({}); + setVariableStatus({ loading: false, error: "" }); loadTree(); } }, [open, loadTree]); @@ -131,24 +140,170 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { if (node && !node.isFolder) { setSelectedPath(node.path); setError(""); + setVariableErrors({}); } }; + const normalizeVariables = (list) => { + if (!Array.isArray(list)) return []; + return list + .map((raw) => { + if (!raw || typeof raw !== "object") return null; + const name = typeof raw.name === "string" ? raw.name.trim() : typeof raw.key === "string" ? raw.key.trim() : ""; + if (!name) return null; + const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string"; + const label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name; + const description = typeof raw.description === "string" ? raw.description : ""; + const required = Boolean(raw.required); + const defaultValue = raw.hasOwnProperty("default") + ? raw.default + : raw.hasOwnProperty("defaultValue") + ? raw.defaultValue + : raw.hasOwnProperty("default_value") + ? raw.default_value + : ""; + return { name, label, type, description, required, default: defaultValue }; + }) + .filter(Boolean); + }; + + const deriveInitialValue = (variable) => { + const { type, default: defaultValue } = variable; + if (type === "boolean") { + if (typeof defaultValue === "boolean") return defaultValue; + if (defaultValue == null) return false; + const str = String(defaultValue).trim().toLowerCase(); + if (!str) return false; + return ["true", "1", "yes", "on"].includes(str); + } + if (type === "number") { + if (defaultValue == null || defaultValue === "") return ""; + if (typeof defaultValue === "number" && Number.isFinite(defaultValue)) { + return String(defaultValue); + } + const parsed = Number(defaultValue); + return Number.isFinite(parsed) ? String(parsed) : ""; + } + return defaultValue == null ? "" : String(defaultValue); + }; + + useEffect(() => { + if (!selectedPath) { + setVariables([]); + setVariableValues({}); + setVariableErrors({}); + setVariableStatus({ loading: false, error: "" }); + return; + } + let canceled = false; + const loadAssembly = async () => { + setVariableStatus({ loading: true, error: "" }); + try { + const island = mode === "ansible" ? "ansible" : "scripts"; + const relPath = island === "scripts" + ? (selectedPath.startsWith("Scripts/") ? selectedPath : `Scripts/${selectedPath}`) + : selectedPath; + const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(relPath)}`); + if (!resp.ok) throw new Error(`Failed to load assembly (HTTP ${resp.status})`); + const data = await resp.json(); + const defs = normalizeVariables(data?.assembly?.variables || []); + if (!canceled) { + setVariables(defs); + const initialValues = {}; + defs.forEach((v) => { + initialValues[v.name] = deriveInitialValue(v); + }); + setVariableValues(initialValues); + setVariableErrors({}); + setVariableStatus({ loading: false, error: "" }); + } + } catch (err) { + if (!canceled) { + setVariables([]); + setVariableValues({}); + setVariableErrors({}); + setVariableStatus({ loading: false, error: err?.message || String(err) }); + } + } + }; + loadAssembly(); + return () => { + canceled = true; + }; + }, [selectedPath, mode]); + + const handleVariableChange = (variable, rawValue) => { + const { name, type } = variable; + if (!name) return; + setVariableValues((prev) => ({ + ...prev, + [name]: type === "boolean" ? Boolean(rawValue) : rawValue + })); + setVariableErrors((prev) => { + if (!prev[name]) return prev; + const next = { ...prev }; + delete next[name]; + return next; + }); + }; + + const buildVariablePayload = () => { + const payload = {}; + variables.forEach((variable) => { + if (!variable?.name) return; + const { name, type } = variable; + const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, name); + const raw = hasOverride ? variableValues[name] : deriveInitialValue(variable); + if (type === "boolean") { + payload[name] = Boolean(raw); + } else if (type === "number") { + if (raw === "" || raw === null || raw === undefined) { + payload[name] = ""; + } else { + const num = Number(raw); + payload[name] = Number.isFinite(num) ? num : ""; + } + } else { + payload[name] = raw == null ? "" : String(raw); + } + }); + return payload; + }; + const onRun = async () => { if (!selectedPath) { setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run."); return; } + if (variables.length) { + const errors = {}; + variables.forEach((variable) => { + if (!variable) return; + if (!variable.required) return; + if (variable.type === "boolean") return; + const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, variable.name); + const raw = hasOverride ? variableValues[variable.name] : deriveInitialValue(variable); + if (raw == null || raw === "") { + errors[variable.name] = "Required"; + } + }); + if (Object.keys(errors).length) { + setVariableErrors(errors); + setError("Please fill in all required variable values."); + return; + } + } setRunning(true); setError(""); try { let resp; + const variableOverrides = buildVariablePayload(); if (mode === 'ansible') { const playbook_path = selectedPath; // relative to ansible island resp = await fetch("/api/ansible/quick_run", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ playbook_path, hostnames }) + body: JSON.stringify({ playbook_path, hostnames, variable_values: variableOverrides }) }); } else { // quick_run expects a path relative to Assemblies root with 'Scripts/' prefix @@ -156,7 +311,12 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { resp = await fetch("/api/scripts/quick_run", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ script_path, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" }) + body: JSON.stringify({ + script_path, + hostnames, + run_mode: runAsCurrentUser ? "current_user" : "system", + variable_values: variableOverrides + }) }); } const data = await resp.json(); @@ -210,6 +370,61 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { )} + + Variables + {variableStatus.loading ? ( + Loading variables… + ) : variableStatus.error ? ( + {variableStatus.error} + ) : variables.length ? ( + + {variables.map((variable) => ( + + {variable.type === "boolean" ? ( + handleVariableChange(variable, e.target.checked)} + /> + )} + label={ + + {variable.label} + {variable.required ? " *" : ""} + + } + /> + ) : ( + handleVariableChange(variable, e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ + "& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" }, + "& .MuiInputBase-input": { color: "#e6edf3" } + }} + error={Boolean(variableErrors[variable.name])} + helperText={variableErrors[variable.name] || variable.description || ""} + /> + )} + {variable.type === "boolean" && variable.description ? ( + + {variable.description} + + ) : null} + + ))} + + ) : ( + No variables defined for this assembly. + )} + {error && ( {error} )} diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index e6fd70e..b13b6ae 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -25,6 +25,14 @@ def _now_ts() -> int: return int(time.time()) +def _env_string(value: Any) -> str: + if isinstance(value, bool): + return "True" if value else "False" + if value is None: + return "" + return str(value) + + def _parse_ts(val: Any) -> Optional[int]: """Best effort to parse ISO-ish datetime string or numeric seconds to epoch seconds.""" if val is None: @@ -336,14 +344,21 @@ class JobScheduler: except Exception: pass - def _dispatch_script(self, hostname: str, rel_path: str, run_mode: str) -> None: + def _dispatch_script(self, hostname: str, component: Dict[str, Any], run_mode: str) -> None: """Emit a quick_job_run event to agents for the given script/host. Mirrors /api/scripts/quick_run behavior for scheduled jobs. """ try: scripts_root = self._scripts_root() import os - path_norm = (rel_path or "").replace("\\", "/") + rel_path_raw = "" + if isinstance(component, dict): + rel_path_raw = str(component.get("path") or component.get("script_path") or "") + else: + rel_path_raw = str(component or "") + path_norm = (rel_path_raw or "").replace("\\", "/").strip() + if path_norm and not path_norm.startswith("Scripts/"): + path_norm = f"Scripts/{path_norm}" abs_path = os.path.abspath(os.path.join(scripts_root, path_norm)) if (not abs_path.startswith(scripts_root)) or (not self._is_valid_scripts_relpath(path_norm)) or (not os.path.isfile(abs_path)): return @@ -353,8 +368,28 @@ class JobScheduler: if stype != "powershell": return content = doc.get("script") or "" + doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else [] + + overrides: Dict[str, Any] = {} + if isinstance(component, dict): + if isinstance(component.get("variable_values"), dict): + for key, val in component.get("variable_values").items(): + name = str(key or "").strip() + if name: + overrides[name] = val + if isinstance(component.get("variables"), list): + for var in component.get("variables"): + if not isinstance(var, dict): + continue + name = str(var.get("name") or "").strip() + if not name: + continue + if "value" in var: + overrides[name] = var.get("value") + env_map: Dict[str, str] = {} - for var in doc.get("variables") or []: + doc_names: Dict[str, bool] = {} + for var in doc_variables: if not isinstance(var, dict): continue name = str(var.get("name") or "").strip() @@ -362,13 +397,32 @@ class JobScheduler: continue env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper()) default_val = var.get("default") - if isinstance(default_val, bool): - env_val = "True" if default_val else "False" - elif default_val is None: - env_val = "" + if default_val is None and "defaultValue" in var: + default_val = var.get("defaultValue") + if default_val is None and "default_value" in var: + default_val = var.get("default_value") + env_map[env_key] = _env_string(default_val) + doc_names[name] = True + for name, val in overrides.items(): + env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper()) + env_map[env_key] = _env_string(val) + + variables: List[Dict[str, Any]] = [] + for var in doc_variables: + if not isinstance(var, dict): + continue + name = str(var.get("name") or "").strip() + if not name: + continue + if name in overrides: + new_var = dict(var) + new_var["value"] = overrides[name] + variables.append(new_var) else: - env_val = str(default_val) - env_map[env_key] = env_val + variables.append(var) + for name, val in overrides.items(): + if name not in doc_names: + variables.append({"name": name, "value": val}) timeout_seconds = 0 try: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) @@ -411,7 +465,7 @@ class JobScheduler: "script_path": path_norm, "script_content": content, "environment": env_map, - "variables": doc.get("variables") or [], + "variables": variables, "timeout_seconds": timeout_seconds, "files": doc.get("files") or [], "run_mode": (run_mode or "system").strip().lower(), @@ -653,7 +707,7 @@ class JobScheduler: comps = json.loads(components_json or "[]") except Exception: comps = [] - script_paths = [] + script_components = [] ansible_paths = [] for c in comps: try: @@ -661,7 +715,9 @@ class JobScheduler: if ctype == "script": p = (c.get("path") or c.get("script_path") or "").strip() if p: - script_paths.append(p) + comp_copy = dict(c) + comp_copy["path"] = p + script_components.append(comp_copy) elif ctype == "ansible": p = (c.get("path") or "").strip() if p: @@ -755,9 +811,9 @@ class JobScheduler: run_row_id = c2.lastrowid or 0 conn2.commit() # Dispatch all script components for this job to the target host - for sp in script_paths: + for comp in script_components: try: - self._dispatch_script(host, sp, run_mode) + self._dispatch_script(host, comp, run_mode) except Exception: continue # Dispatch ansible playbooks for this job to the target host diff --git a/Data/Server/server.py b/Data/Server/server.py index 631eb86..63f032b 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -2843,9 +2843,18 @@ def scripts_quick_run(): return jsonify({"error": f"Unsupported script type '{script_type}'. Only powershell is supported for Quick Job currently."}), 400 content = doc.get("script") or "" - variables = doc.get("variables") if isinstance(doc.get("variables"), list) else [] + doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else [] + + def _env_string(value: Any) -> str: + if isinstance(value, bool): + return "True" if value else "False" + if value is None: + return "" + return str(value) + env_map: Dict[str, str] = {} - for var in variables: + doc_names: Dict[str, bool] = {} + for var in doc_variables: if not isinstance(var, dict): continue name = str(var.get("name") or "").strip() @@ -2853,13 +2862,40 @@ def scripts_quick_run(): continue env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper()) default_val = var.get("default") - if isinstance(default_val, bool): - env_val = "True" if default_val else "False" - elif default_val is None: - env_val = "" + if default_val is None and "defaultValue" in var: + default_val = var.get("defaultValue") + if default_val is None and "default_value" in var: + default_val = var.get("default_value") + env_map[env_key] = _env_string(default_val) + doc_names[name] = True + + overrides_raw = data.get("variable_values") + overrides: Dict[str, Any] = {} + if isinstance(overrides_raw, dict): + for key, val in overrides_raw.items(): + name = str(key or "").strip() + if not name: + continue + overrides[name] = val + env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper()) + env_map[env_key] = _env_string(val) + + variables: List[Dict[str, Any]] = [] + for var in doc_variables: + if not isinstance(var, dict): + continue + name = str(var.get("name") or "").strip() + if not name: + continue + if name in overrides: + new_var = dict(var) + new_var["value"] = overrides[name] + variables.append(new_var) else: - env_val = str(default_val) - env_map[env_key] = env_val + variables.append(var) + for name, val in overrides.items(): + if name not in doc_names: + variables.append({"name": name, "value": val}) timeout_seconds = 0 try: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) From 3d0a6474ce7ba6f70bbf50a6745f710188e68c0e Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 3 Oct 2025 17:56:13 -0600 Subject: [PATCH 2/8] Fix assembly variable loading paths --- Data/Server/WebUI/src/Scheduling/Create_Job.jsx | 11 ++++++++++- Data/Server/WebUI/src/Scheduling/Quick_Job.jsx | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx index 92893d9..fcfbab3 100644 --- a/Data/Server/WebUI/src/Scheduling/Create_Job.jsx +++ b/Data/Server/WebUI/src/Scheduling/Create_Job.jsx @@ -362,9 +362,18 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) { const fetchAssemblyDoc = useCallback(async (type, rawPath) => { const normalizedPath = normalizeComponentPath(type, rawPath); if (!normalizedPath) return { doc: null, normalizedPath: "" }; + const trimmed = normalizedPath.replace(/\\/g, "/").replace(/^\/+/, "").trim(); + if (!trimmed) return { doc: null, normalizedPath: "" }; + let requestPath = trimmed; + if (type === "script" && requestPath.toLowerCase().startsWith("scripts/")) { + requestPath = requestPath.slice("Scripts/".length); + } else if (type === "ansible" && requestPath.toLowerCase().startsWith("ansible_playbooks/")) { + requestPath = requestPath.slice("Ansible_Playbooks/".length); + } + if (!requestPath) return { doc: null, normalizedPath }; try { const island = type === "ansible" ? "ansible" : "scripts"; - const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(normalizedPath)}`); + const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(requestPath)}`); if (!resp.ok) { return { doc: null, normalizedPath }; } diff --git a/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx index 9257ec8..59033b0 100644 --- a/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx +++ b/Data/Server/WebUI/src/Scheduling/Quick_Job.jsx @@ -200,9 +200,20 @@ export default function QuickJob({ open, onClose, hostnames = [] }) { setVariableStatus({ loading: true, error: "" }); try { const island = mode === "ansible" ? "ansible" : "scripts"; - const relPath = island === "scripts" - ? (selectedPath.startsWith("Scripts/") ? selectedPath : `Scripts/${selectedPath}`) - : selectedPath; + const trimmed = (selectedPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim(); + if (!trimmed) { + setVariables([]); + setVariableValues({}); + setVariableErrors({}); + setVariableStatus({ loading: false, error: "" }); + return; + } + let relPath = trimmed; + if (island === "scripts" && relPath.toLowerCase().startsWith("scripts/")) { + relPath = relPath.slice("Scripts/".length); + } else if (island === "ansible" && relPath.toLowerCase().startsWith("ansible_playbooks/")) { + relPath = relPath.slice("Ansible_Playbooks/".length); + } const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(relPath)}`); if (!resp.ok) throw new Error(`Failed to load assembly (HTTP ${resp.status})`); const data = await resp.json(); From acf03ece9629fbdef6eb65b3f4bd9c324e9193fe Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 3 Oct 2025 18:17:39 -0600 Subject: [PATCH 3/8] Ensure script variables are available under original names --- .../Roles/role_ScriptExec_CURRENTUSER.py | 33 ++++++++++++++++-- Data/Agent/Roles/role_ScriptExec_SYSTEM.py | 34 +++++++++++++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py index b4c3d25..f9049e6 100644 --- a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py +++ b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py @@ -15,6 +15,11 @@ ROLE_CONTEXTS = ['interactive'] IS_WINDOWS = os.name == 'nt' +def _canonical_env_key(name: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip()) + return cleaned.upper() + + def _sanitize_env_map(raw) -> Dict[str, str]: env: Dict[str, str] = {} if isinstance(raw, dict): @@ -24,7 +29,7 @@ def _sanitize_env_map(raw) -> Dict[str, str]: name = str(key).strip() if not name: continue - env_key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper() + env_key = _canonical_env_key(name) if not env_key: continue if isinstance(value, bool): @@ -37,6 +42,29 @@ def _sanitize_env_map(raw) -> Dict[str, str]: return env +def _apply_variable_aliases(env_map: Dict[str, str], variables: List[Dict[str, str]]) -> Dict[str, str]: + if not isinstance(env_map, dict) or not isinstance(variables, list): + return env_map + for var in variables: + if not isinstance(var, dict): + continue + name = str(var.get('name') or '').strip() + if not name: + continue + canonical = _canonical_env_key(name) + if not canonical or canonical not in env_map: + continue + value = env_map[canonical] + alias = re.sub(r"[^A-Za-z0-9_]", "_", name) + if alias and alias not in env_map: + env_map[alias] = value + if alias == name: + continue + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in env_map: + env_map[name] = value + return env_map + + def _ps_literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" @@ -201,7 +229,7 @@ class Role: name = str(var.get('name') or '').strip() if not name: continue - key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper() + key = _canonical_env_key(name) if key in env_map: continue default_val = var.get('default') @@ -211,6 +239,7 @@ class Role: env_map[key] = "" else: env_map[key] = str(default_val) + env_map = _apply_variable_aliases(env_map, variables) try: timeout_seconds = max(0, int(payload.get('timeout_seconds') or 0)) except Exception: diff --git a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py index c3c4794..0ba5852 100644 --- a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py +++ b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py @@ -16,6 +16,11 @@ def _project_root(): return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +def _canonical_env_key(name: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip()) + return cleaned.upper() + + def _sanitize_env_map(raw) -> Dict[str, str]: env: Dict[str, str] = {} if isinstance(raw, dict): @@ -25,7 +30,7 @@ def _sanitize_env_map(raw) -> Dict[str, str]: name = str(key).strip() if not name: continue - env_key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper() + env_key = _canonical_env_key(name) if not env_key: continue if isinstance(value, bool): @@ -38,6 +43,30 @@ def _sanitize_env_map(raw) -> Dict[str, str]: return env +def _apply_variable_aliases(env_map: Dict[str, str], variables: List[Dict[str, str]]) -> Dict[str, str]: + if not isinstance(env_map, dict) or not isinstance(variables, list): + return env_map + for var in variables: + if not isinstance(var, dict): + continue + name = str(var.get('name') or '').strip() + if not name: + continue + canonical = _canonical_env_key(name) + if not canonical or canonical not in env_map: + continue + value = env_map[canonical] + alias = re.sub(r"[^A-Za-z0-9_]", "_", name) + if alias and alias not in env_map: + env_map[alias] = value + if alias == name: + continue + # Only add the original name when it results in a valid identifier. + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in env_map: + env_map[name] = value + return env_map + + def _ps_literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" @@ -194,7 +223,7 @@ class Role: name = str(var.get('name') or '').strip() if not name: continue - key = re.sub(r"[^A-Za-z0-9_]", "_", name).upper() + key = _canonical_env_key(name) if key in env_map: continue default_val = var.get('default') @@ -204,6 +233,7 @@ class Role: env_map[key] = "" else: env_map[key] = str(default_val) + env_map = _apply_variable_aliases(env_map, variables) try: timeout_seconds = max(0, int(payload.get('timeout_seconds') or 0)) except Exception: From 8e83939ee89977b4309e89c216efcb391a95a9e4 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 3 Oct 2025 18:31:07 -0600 Subject: [PATCH 4/8] Ensure script wrapper sets process environment --- Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py | 7 ++++++- Data/Agent/Roles/role_ScriptExec_SYSTEM.py | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py index f9049e6..5195fd7 100644 --- a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py +++ b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py @@ -74,7 +74,12 @@ def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds for key, value in (env_map or {}).items(): if not key: continue - inner_lines.append(f"$Env:{key} = {_ps_literal(value)}") + value_literal = _ps_literal(value) + key_literal = _ps_literal(key) + inner_lines.append( + f"[System.Environment]::SetEnvironmentVariable({key_literal}, {value_literal}, 'Process')" + ) + inner_lines.append(f"$Env:{key} = {value_literal}") inner_lines.append(content or "") inner = "\n".join(line for line in inner_lines if line is not None) script_block = "$__BorealisScript = {\n" + inner + "\n}\n" diff --git a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py index 0ba5852..de45cc2 100644 --- a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py +++ b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py @@ -76,7 +76,12 @@ def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds for key, value in (env_map or {}).items(): if not key: continue - inner_lines.append(f"$Env:{key} = {_ps_literal(value)}") + value_literal = _ps_literal(value) + key_literal = _ps_literal(key) + inner_lines.append( + f"[System.Environment]::SetEnvironmentVariable({key_literal}, {value_literal}, 'Process')" + ) + inner_lines.append(f"$Env:{key} = {value_literal}") inner_lines.append(content or "") inner = "\n".join(line for line in inner_lines if line is not None) script_block = "$__BorealisScript = {\n" + inner + "\n}\n" From 38863be3345743fe1bd3239852d3ee59a4e7269a Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 3 Oct 2025 18:47:00 -0600 Subject: [PATCH 5/8] Stabilize job environment variable injection --- .../Roles/role_ScriptExec_CURRENTUSER.py | 38 ++++++++++++++----- Data/Agent/Roles/role_ScriptExec_SYSTEM.py | 38 ++++++++++++++----- Data/Server/job_scheduler.py | 38 ++++++++++++++++++- Data/Server/server.py | 34 ++++++++++++++++- 4 files changed, 124 insertions(+), 24 deletions(-) diff --git a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py index 5195fd7..1becb19 100644 --- a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py +++ b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py @@ -70,19 +70,37 @@ def _ps_literal(value: str) -> str: def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds: int) -> str: + def _env_assignment_lines(lines: List[str]) -> None: + for key, value in (env_map or {}).items(): + if not key: + continue + value_literal = _ps_literal(value) + key_literal = _ps_literal(key) + env_path_literal = f"[string]::Format('Env:{{0}}', {key_literal})" + lines.append( + f"try {{ [System.Environment]::SetEnvironmentVariable({key_literal}, {value_literal}, 'Process') }} catch {{}}" + ) + lines.append( + "try { Set-Item -LiteralPath (" + env_path_literal + ") -Value " + value_literal + + " -ErrorAction Stop } catch { try { New-Item -Path (" + env_path_literal + ") -Value " + + value_literal + " -Force | Out-Null } catch {} }" + ) + + prelude_lines: List[str] = [] + _env_assignment_lines(prelude_lines) + inner_lines: List[str] = [] - for key, value in (env_map or {}).items(): - if not key: - continue - value_literal = _ps_literal(value) - key_literal = _ps_literal(key) - inner_lines.append( - f"[System.Environment]::SetEnvironmentVariable({key_literal}, {value_literal}, 'Process')" - ) - inner_lines.append(f"$Env:{key} = {value_literal}") + _env_assignment_lines(inner_lines) inner_lines.append(content or "") + + prelude = "\n".join(prelude_lines) inner = "\n".join(line for line in inner_lines if line is not None) - script_block = "$__BorealisScript = {\n" + inner + "\n}\n" + + pieces: List[str] = [] + if prelude: + pieces.append(prelude) + pieces.append("$__BorealisScript = {\n" + inner + "\n}\n") + script_block = "\n".join(pieces) if timeout_seconds and timeout_seconds > 0: block = ( "$job = Start-Job -ScriptBlock $__BorealisScript\n" diff --git a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py index de45cc2..340e63f 100644 --- a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py +++ b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py @@ -72,19 +72,37 @@ def _ps_literal(value: str) -> str: def _build_wrapped_script(content: str, env_map: Dict[str, str], timeout_seconds: int) -> str: + def _env_assignment_lines(lines: List[str]) -> None: + for key, value in (env_map or {}).items(): + if not key: + continue + value_literal = _ps_literal(value) + key_literal = _ps_literal(key) + env_path_literal = f"[string]::Format('Env:{{0}}', {key_literal})" + lines.append( + f"try {{ [System.Environment]::SetEnvironmentVariable({key_literal}, {value_literal}, 'Process') }} catch {{}}" + ) + lines.append( + "try { Set-Item -LiteralPath (" + env_path_literal + ") -Value " + value_literal + + " -ErrorAction Stop } catch { try { New-Item -Path (" + env_path_literal + ") -Value " + + value_literal + " -Force | Out-Null } catch {} }" + ) + + prelude_lines: List[str] = [] + _env_assignment_lines(prelude_lines) + inner_lines: List[str] = [] - for key, value in (env_map or {}).items(): - if not key: - continue - value_literal = _ps_literal(value) - key_literal = _ps_literal(key) - inner_lines.append( - f"[System.Environment]::SetEnvironmentVariable({key_literal}, {value_literal}, 'Process')" - ) - inner_lines.append(f"$Env:{key} = {value_literal}") + _env_assignment_lines(inner_lines) inner_lines.append(content or "") + + prelude = "\n".join(prelude_lines) inner = "\n".join(line for line in inner_lines if line is not None) - script_block = "$__BorealisScript = {\n" + inner + "\n}\n" + + pieces: List[str] = [] + if prelude: + pieces.append(prelude) + pieces.append("$__BorealisScript = {\n" + inner + "\n}\n") + script_block = "\n".join(pieces) if timeout_seconds and timeout_seconds > 0: block = ( "$job = Start-Job -ScriptBlock $__BorealisScript\n" diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index b13b6ae..74c381b 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -33,6 +33,35 @@ def _env_string(value: Any) -> str: return str(value) +def _canonical_env_key(name: Any) -> str: + try: + return re.sub(r"[^A-Za-z0-9_]", "_", str(name or "").strip()).upper() + except Exception: + return "" + + +def _expand_env_aliases(env_map: Dict[str, str], variables: List[Dict[str, Any]]) -> Dict[str, str]: + expanded: Dict[str, str] = dict(env_map or {}) + if not isinstance(variables, list): + return expanded + for var in variables: + if not isinstance(var, dict): + continue + name = str(var.get("name") or "").strip() + if not name: + continue + canonical = _canonical_env_key(name) + if not canonical or canonical not in expanded: + continue + value = expanded[canonical] + alias = re.sub(r"[^A-Za-z0-9_]", "_", name) + if alias and alias not in expanded: + expanded[alias] = value + if alias != name and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in expanded: + expanded[name] = value + return expanded + + def _parse_ts(val: Any) -> Optional[int]: """Best effort to parse ISO-ish datetime string or numeric seconds to epoch seconds.""" if val is None: @@ -395,7 +424,9 @@ class JobScheduler: name = str(var.get("name") or "").strip() if not name: continue - env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper()) + env_key = _canonical_env_key(name) + if not env_key: + continue default_val = var.get("default") if default_val is None and "defaultValue" in var: default_val = var.get("defaultValue") @@ -404,7 +435,9 @@ class JobScheduler: env_map[env_key] = _env_string(default_val) doc_names[name] = True for name, val in overrides.items(): - env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper()) + env_key = _canonical_env_key(name) + if not env_key: + continue env_map[env_key] = _env_string(val) variables: List[Dict[str, Any]] = [] @@ -423,6 +456,7 @@ class JobScheduler: for name, val in overrides.items(): if name not in doc_names: variables.append({"name": name, "value": val}) + env_map = _expand_env_aliases(env_map, variables) timeout_seconds = 0 try: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) diff --git a/Data/Server/server.py b/Data/Server/server.py index 63f032b..d92e9c1 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -2816,6 +2816,35 @@ def _safe_filename(rel_path: str) -> str: return rel_path or "" +def _canonical_env_key(name: Any) -> str: + try: + return re.sub(r"[^A-Za-z0-9_]", "_", str(name or "").strip()).upper() + except Exception: + return "" + + +def _expand_env_aliases(env_map: Dict[str, str], variables: List[Dict[str, Any]]) -> Dict[str, str]: + expanded: Dict[str, str] = dict(env_map or {}) + if not isinstance(variables, list): + return expanded + for var in variables: + if not isinstance(var, dict): + continue + name = str(var.get("name") or "").strip() + if not name: + continue + canonical = _canonical_env_key(name) + if not canonical or canonical not in expanded: + continue + value = expanded[canonical] + alias = re.sub(r"[^A-Za-z0-9_]", "_", name) + if alias and alias not in expanded: + expanded[alias] = value + if alias != name and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in expanded: + expanded[name] = value + return expanded + + @app.route("/api/scripts/quick_run", methods=["POST"]) def scripts_quick_run(): """Queue a Quick Job to agents via WebSocket and record Running status. @@ -2860,7 +2889,7 @@ def scripts_quick_run(): name = str(var.get("name") or "").strip() if not name: continue - env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper()) + env_key = _canonical_env_key(name) default_val = var.get("default") if default_val is None and "defaultValue" in var: default_val = var.get("defaultValue") @@ -2877,7 +2906,7 @@ def scripts_quick_run(): if not name: continue overrides[name] = val - env_key = re.sub(r"[^A-Za-z0-9_]", "_", name.upper()) + env_key = _canonical_env_key(name) env_map[env_key] = _env_string(val) variables: List[Dict[str, Any]] = [] @@ -2896,6 +2925,7 @@ def scripts_quick_run(): for name, val in overrides.items(): if name not in doc_names: variables.append({"name": name, "value": val}) + env_map = _expand_env_aliases(env_map, variables) timeout_seconds = 0 try: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) From 304c1e97282ba5cba553c8a9be2cd789d567e154 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 3 Oct 2025 19:05:30 -0600 Subject: [PATCH 6/8] Rewrite PowerShell jobs with substituted variable literals --- Data/Server/job_scheduler.py | 142 +++++++++++++++++++++++++---------- Data/Server/server.py | 139 +++++++++++++++++++++++++--------- 2 files changed, 204 insertions(+), 77 deletions(-) diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index 74c381b..bfe6ff6 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -62,6 +62,105 @@ def _expand_env_aliases(env_map: Dict[str, str], variables: List[Dict[str, Any]] return expanded +def _powershell_literal(value: Any, var_type: str) -> str: + typ = str(var_type or "string").lower() + if typ == "boolean": + if isinstance(value, bool): + truthy = value + elif value is None: + truthy = False + elif isinstance(value, (int, float)): + truthy = value != 0 + else: + s = str(value).strip().lower() + if s in {"true", "1", "yes", "y", "on"}: + truthy = True + elif s in {"false", "0", "no", "n", "off", ""}: + truthy = False + else: + truthy = bool(s) + return "$true" if truthy else "$false" + if typ == "number": + if value is None or value == "": + return "0" + return str(value) + s = "" if value is None else str(value) + return "'" + s.replace("'", "''") + "'" + + +def _extract_variable_default(var: Dict[str, Any]) -> Any: + for key in ("value", "default", "defaultValue", "default_value"): + if key in var: + val = var.get(key) + return "" if val is None else val + return "" + + +def _prepare_variable_context(doc_variables: List[Dict[str, Any]], overrides: Dict[str, Any]): + env_map: Dict[str, str] = {} + variables: List[Dict[str, Any]] = [] + literal_lookup: Dict[str, str] = {} + doc_names: Dict[str, bool] = {} + + overrides = overrides or {} + + if not isinstance(doc_variables, list): + doc_variables = [] + + for var in doc_variables: + if not isinstance(var, dict): + continue + name = str(var.get("name") or "").strip() + if not name: + continue + doc_names[name] = True + canonical = _canonical_env_key(name) + var_type = str(var.get("type") or "string").lower() + default_val = _extract_variable_default(var) + final_val = overrides[name] if name in overrides else default_val + if canonical: + env_map[canonical] = _env_string(final_val) + literal_lookup[canonical] = _powershell_literal(final_val, var_type) + if name in overrides: + new_var = dict(var) + new_var["value"] = overrides[name] + variables.append(new_var) + else: + variables.append(var) + + for name, val in overrides.items(): + if name in doc_names: + continue + canonical = _canonical_env_key(name) + if canonical: + env_map[canonical] = _env_string(val) + literal_lookup[canonical] = _powershell_literal(val, "string") + variables.append({"name": name, "value": val, "type": "string"}) + + env_map = _expand_env_aliases(env_map, variables) + return env_map, variables, literal_lookup + + +_ENV_VAR_PATTERN = re.compile(r"(?i)\$env:(\{)?([A-Za-z0-9_\-]+)(?(1)\})") + + +def _rewrite_powershell_script(content: str, literal_lookup: Dict[str, str]) -> str: + if not content or not literal_lookup: + return content + + def _replace(match: Any) -> str: + name = match.group(2) + canonical = _canonical_env_key(name) + if not canonical: + return match.group(0) + literal = literal_lookup.get(canonical) + if literal is None: + return match.group(0) + return literal + + return _ENV_VAR_PATTERN.sub(_replace, content) + + def _parse_ts(val: Any) -> Optional[int]: """Best effort to parse ISO-ish datetime string or numeric seconds to epoch seconds.""" if val is None: @@ -416,47 +515,8 @@ class JobScheduler: if "value" in var: overrides[name] = var.get("value") - env_map: Dict[str, str] = {} - doc_names: Dict[str, bool] = {} - for var in doc_variables: - if not isinstance(var, dict): - continue - name = str(var.get("name") or "").strip() - if not name: - continue - env_key = _canonical_env_key(name) - if not env_key: - continue - default_val = var.get("default") - if default_val is None and "defaultValue" in var: - default_val = var.get("defaultValue") - if default_val is None and "default_value" in var: - default_val = var.get("default_value") - env_map[env_key] = _env_string(default_val) - doc_names[name] = True - for name, val in overrides.items(): - env_key = _canonical_env_key(name) - if not env_key: - continue - env_map[env_key] = _env_string(val) - - variables: List[Dict[str, Any]] = [] - for var in doc_variables: - if not isinstance(var, dict): - continue - name = str(var.get("name") or "").strip() - if not name: - continue - if name in overrides: - new_var = dict(var) - new_var["value"] = overrides[name] - variables.append(new_var) - else: - variables.append(var) - for name, val in overrides.items(): - if name not in doc_names: - variables.append({"name": name, "value": val}) - env_map = _expand_env_aliases(env_map, variables) + env_map, variables, literal_lookup = _prepare_variable_context(doc_variables, overrides) + content = _rewrite_powershell_script(content, literal_lookup) timeout_seconds = 0 try: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) diff --git a/Data/Server/server.py b/Data/Server/server.py index d92e9c1..411a89f 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -2845,6 +2845,107 @@ def _expand_env_aliases(env_map: Dict[str, str], variables: List[Dict[str, Any]] return expanded +def _powershell_literal(value: Any, var_type: str) -> str: + """Convert a variable value to a PowerShell literal for substitution.""" + typ = str(var_type or "string").lower() + if typ == "boolean": + if isinstance(value, bool): + truthy = value + elif value is None: + truthy = False + elif isinstance(value, (int, float)): + truthy = value != 0 + else: + s = str(value).strip().lower() + if s in {"true", "1", "yes", "y", "on"}: + truthy = True + elif s in {"false", "0", "no", "n", "off", ""}: + truthy = False + else: + truthy = bool(s) + return "$true" if truthy else "$false" + if typ == "number": + if value is None or value == "": + return "0" + return str(value) + # Treat credentials and any other type as strings + s = "" if value is None else str(value) + return "'" + s.replace("'", "''") + "'" + + +def _extract_variable_default(var: Dict[str, Any]) -> Any: + for key in ("value", "default", "defaultValue", "default_value"): + if key in var: + val = var.get(key) + return "" if val is None else val + return "" + + +def _prepare_variable_context(doc_variables: List[Dict[str, Any]], overrides: Dict[str, Any]): + env_map: Dict[str, str] = {} + variables: List[Dict[str, Any]] = [] + literal_lookup: Dict[str, str] = {} + doc_names: Dict[str, bool] = {} + + overrides = overrides or {} + + if not isinstance(doc_variables, list): + doc_variables = [] + + for var in doc_variables: + if not isinstance(var, dict): + continue + name = str(var.get("name") or "").strip() + if not name: + continue + doc_names[name] = True + canonical = _canonical_env_key(name) + var_type = str(var.get("type") or "string").lower() + default_val = _extract_variable_default(var) + final_val = overrides[name] if name in overrides else default_val + if canonical: + env_map[canonical] = _env_string(final_val) + literal_lookup[canonical] = _powershell_literal(final_val, var_type) + if name in overrides: + new_var = dict(var) + new_var["value"] = overrides[name] + variables.append(new_var) + else: + variables.append(var) + + for name, val in overrides.items(): + if name in doc_names: + continue + canonical = _canonical_env_key(name) + if canonical: + env_map[canonical] = _env_string(val) + literal_lookup[canonical] = _powershell_literal(val, "string") + variables.append({"name": name, "value": val, "type": "string"}) + + env_map = _expand_env_aliases(env_map, variables) + return env_map, variables, literal_lookup + + +_ENV_VAR_PATTERN = re.compile(r"(?i)\$env:(\{)?([A-Za-z0-9_\-]+)(?(1)\})") + + +def _rewrite_powershell_script(content: str, literal_lookup: Dict[str, str]) -> str: + if not content or not literal_lookup: + return content + + def _replace(match: Any) -> str: + name = match.group(2) + canonical = _canonical_env_key(name) + if not canonical: + return match.group(0) + literal = literal_lookup.get(canonical) + if literal is None: + return match.group(0) + return literal + + return _ENV_VAR_PATTERN.sub(_replace, content) + + @app.route("/api/scripts/quick_run", methods=["POST"]) def scripts_quick_run(): """Queue a Quick Job to agents via WebSocket and record Running status. @@ -2881,23 +2982,6 @@ def scripts_quick_run(): return "" return str(value) - env_map: Dict[str, str] = {} - doc_names: Dict[str, bool] = {} - for var in doc_variables: - if not isinstance(var, dict): - continue - name = str(var.get("name") or "").strip() - if not name: - continue - env_key = _canonical_env_key(name) - default_val = var.get("default") - if default_val is None and "defaultValue" in var: - default_val = var.get("defaultValue") - if default_val is None and "default_value" in var: - default_val = var.get("default_value") - env_map[env_key] = _env_string(default_val) - doc_names[name] = True - overrides_raw = data.get("variable_values") overrides: Dict[str, Any] = {} if isinstance(overrides_raw, dict): @@ -2906,26 +2990,9 @@ def scripts_quick_run(): if not name: continue overrides[name] = val - env_key = _canonical_env_key(name) - env_map[env_key] = _env_string(val) - variables: List[Dict[str, Any]] = [] - for var in doc_variables: - if not isinstance(var, dict): - continue - name = str(var.get("name") or "").strip() - if not name: - continue - if name in overrides: - new_var = dict(var) - new_var["value"] = overrides[name] - variables.append(new_var) - else: - variables.append(var) - for name, val in overrides.items(): - if name not in doc_names: - variables.append({"name": name, "value": val}) - env_map = _expand_env_aliases(env_map, variables) + env_map, variables, literal_lookup = _prepare_variable_context(doc_variables, overrides) + content = _rewrite_powershell_script(content, literal_lookup) timeout_seconds = 0 try: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) From 211e37c64cb14a5c15a9437e722d419fd3d6e2d9 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 3 Oct 2025 21:16:43 -0600 Subject: [PATCH 7/8] Persist assemblies as base64 and decode for execution --- Data/Agent/Roles/role_PlaybookExec_SYSTEM.py | 38 +++++++- .../Roles/role_ScriptExec_CURRENTUSER.py | 39 +++++++- Data/Agent/Roles/role_ScriptExec_SYSTEM.py | 39 +++++++- Data/Agent/agent.py | 35 +++++++- .../WebUI/src/Assemblies/Assembly_Editor.jsx | 74 ++++++++++++++- Data/Server/job_scheduler.py | 76 ++++++++++++++-- Data/Server/server.py | 90 +++++++++++++++++-- 7 files changed, 370 insertions(+), 21 deletions(-) diff --git a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py index be9e18b..d3b64e2 100644 --- a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py +++ b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py @@ -7,6 +7,7 @@ import time import json import socket import subprocess +import base64 from typing import Optional try: @@ -39,6 +40,39 @@ def _project_root(): return os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +def _decode_base64_text(value): + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped: + return "" + cleaned = ''.join(stripped.split()) + if not cleaned: + return "" + try: + decoded = base64.b64decode(cleaned, validate=True) + except Exception: + return None + try: + return decoded.decode('utf-8') + except Exception: + return decoded.decode('utf-8', errors='replace') + + +def _decode_playbook_content(raw_content, encoding_hint): + if isinstance(raw_content, str): + encoding = str(encoding_hint or '').strip().lower() + if encoding in ('base64', 'b64', 'base-64'): + decoded = _decode_base64_text(raw_content) + if decoded is not None: + return decoded + decoded = _decode_base64_text(raw_content) + if decoded is not None: + return decoded + return raw_content + return '' + + def _agent_root(): # Resolve Agent root at runtime. # Typical runtime: /Agent/Borealis/Roles/ @@ -801,7 +835,7 @@ try {{ return # Accept provided run_id or generate one run_id = (payload.get('run_id') or '').strip() or uuid.uuid4().hex - content = payload.get('playbook_content') or '' + content = _decode_playbook_content(payload.get('playbook_content'), payload.get('playbook_encoding')) p_name = payload.get('playbook_name') or '' act_id = payload.get('activity_job_id') sched_job_id = payload.get('scheduled_job_id') @@ -874,7 +908,7 @@ try {{ if target and target != hostname.lower(): return run_id = uuid.uuid4().hex - content = payload.get('script_content') or '' + content = _decode_playbook_content(payload.get('script_content'), payload.get('script_encoding')) p_name = payload.get('script_name') or '' self._runs[run_id] = {'cancel': False, 'proc': None} asyncio.create_task(self._run_playbook(run_id, content, playbook_name=p_name, activity_job_id=payload.get('job_id'), connection='local')) diff --git a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py index 1becb19..c2f5f72 100644 --- a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py +++ b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py @@ -4,7 +4,8 @@ import re import asyncio import tempfile import uuid -from typing import Dict, List +import base64 +from typing import Dict, List, Optional from PyQt5 import QtWidgets, QtGui @@ -65,6 +66,40 @@ def _apply_variable_aliases(env_map: Dict[str, str], variables: List[Dict[str, s return env_map +def _decode_base64_text(value: str) -> Optional[str]: + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped: + return "" + try: + cleaned = re.sub(r"\s+", "", stripped) + except Exception: + cleaned = stripped + try: + decoded = base64.b64decode(cleaned, validate=True) + except Exception: + return None + try: + return decoded.decode("utf-8") + except Exception: + return decoded.decode("utf-8", errors="replace") + + +def _decode_script_content(raw_content, encoding_hint) -> str: + if isinstance(raw_content, str): + encoding = str(encoding_hint or "").strip().lower() + if encoding in ("base64", "b64", "base-64"): + decoded = _decode_base64_text(raw_content) + if decoded is not None: + return decoded + decoded = _decode_base64_text(raw_content) + if decoded is not None: + return decoded + return raw_content + return "" + + def _ps_literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" @@ -242,7 +277,7 @@ class Role: job_id = payload.get('job_id') script_type = (payload.get('script_type') or '').lower() run_mode = (payload.get('run_mode') or 'current_user').lower() - content = payload.get('script_content') or '' + content = _decode_script_content(payload.get('script_content'), payload.get('script_encoding')) raw_env = payload.get('environment') env_map = _sanitize_env_map(raw_env) variables = payload.get('variables') if isinstance(payload.get('variables'), list) else [] diff --git a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py index 340e63f..4c8d456 100644 --- a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py +++ b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py @@ -5,7 +5,8 @@ import tempfile import uuid import time import subprocess -from typing import Dict, List +import base64 +from typing import Dict, List, Optional ROLE_NAME = 'script_exec_system' @@ -67,6 +68,40 @@ def _apply_variable_aliases(env_map: Dict[str, str], variables: List[Dict[str, s return env_map +def _decode_base64_text(value: str) -> Optional[str]: + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped: + return "" + try: + cleaned = re.sub(r"\s+", "", stripped) + except Exception: + cleaned = stripped + try: + decoded = base64.b64decode(cleaned, validate=True) + except Exception: + return None + try: + return decoded.decode("utf-8") + except Exception: + return decoded.decode("utf-8", errors="replace") + + +def _decode_script_content(raw_content, encoding_hint) -> str: + if isinstance(raw_content, str): + encoding = str(encoding_hint or "").strip().lower() + if encoding in ("base64", "b64", "base-64"): + decoded = _decode_base64_text(raw_content) + if decoded is not None: + return decoded + decoded = _decode_base64_text(raw_content) + if decoded is not None: + return decoded + return raw_content + return "" + + def _ps_literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" @@ -236,7 +271,7 @@ class Role: return job_id = payload.get('job_id') script_type = (payload.get('script_type') or '').lower() - content = payload.get('script_content') or '' + content = _decode_script_content(payload.get('script_content'), payload.get('script_encoding')) raw_env = payload.get('environment') env_map = _sanitize_env_map(raw_env) variables = payload.get('variables') if isinstance(payload.get('variables'), list) else [] diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index 86e98ac..1cca901 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -156,6 +156,39 @@ def _log_agent(message: str, fname: str = 'agent.log'): except Exception: pass + +def _decode_base64_text(value): + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped: + return "" + cleaned = ''.join(stripped.split()) + if not cleaned: + return "" + try: + decoded = base64.b64decode(cleaned, validate=True) + except Exception: + return None + try: + return decoded.decode('utf-8') + except Exception: + return decoded.decode('utf-8', errors='replace') + + +def _decode_script_payload(content, encoding_hint): + if isinstance(content, str): + encoding = str(encoding_hint or '').strip().lower() + if encoding in ('base64', 'b64', 'base-64'): + decoded = _decode_base64_text(content) + if decoded is not None: + return decoded + decoded = _decode_base64_text(content) + if decoded is not None: + return decoded + return content + return '' + def _resolve_config_path(): """ Resolve the path for agent settings json in the centralized location: @@ -1520,7 +1553,7 @@ if __name__=='__main__': return job_id = payload.get('job_id') script_type = (payload.get('script_type') or '').lower() - content = payload.get('script_content') or '' + content = _decode_script_payload(payload.get('script_content'), payload.get('script_encoding')) run_mode = (payload.get('run_mode') or 'current_user').lower() if script_type != 'powershell': await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" }) diff --git a/Data/Server/WebUI/src/Assemblies/Assembly_Editor.jsx b/Data/Server/WebUI/src/Assemblies/Assembly_Editor.jsx index daa9216..d60fb27 100644 --- a/Data/Server/WebUI/src/Assemblies/Assembly_Editor.jsx +++ b/Data/Server/WebUI/src/Assemblies/Assembly_Editor.jsx @@ -198,6 +198,61 @@ function normalizeVariablesFromServer(vars = []) { })); } +function decodeBase64String(data = "") { + if (typeof data !== "string") { + return { success: false, value: "" }; + } + if (!data.trim()) { + return { success: true, value: "" }; + } + try { + if (typeof window !== "undefined" && typeof window.atob === "function") { + const binary = window.atob(data); + if (typeof TextDecoder !== "undefined") { + const decoder = new TextDecoder("utf-8", { fatal: false }); + return { success: true, value: decoder.decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))) }; + } + return { success: true, value: binary }; + } + } catch (err) { + // fall through to Buffer fallback + } + try { + if (typeof Buffer !== "undefined") { + return { success: true, value: Buffer.from(data, "base64").toString("utf-8") }; + } + } catch (err) { + // ignore + } + return { success: false, value: "" }; +} + +function encodeBase64String(text = "") { + if (typeof text !== "string") { + text = text == null ? "" : String(text); + } + if (!text) return ""; + try { + if (typeof TextEncoder !== "undefined" && typeof window !== "undefined" && typeof window.btoa === "function") { + const encoder = new TextEncoder(); + const bytes = encoder.encode(text); + let binary = ""; + bytes.forEach((b) => { binary += String.fromCharCode(b); }); + return window.btoa(binary); + } + } catch (err) { + // fall through to Buffer fallback + } + try { + if (typeof Buffer !== "undefined") { + return Buffer.from(text, "utf-8").toString("base64"); + } + } catch (err) { + // ignore + } + return ""; +} + function normalizeFilesFromServer(files = []) { return (Array.isArray(files) ? files : []).map((f, idx) => ({ id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`, @@ -219,7 +274,20 @@ function fromServerDocument(doc = {}, defaultType = "powershell") { ? doc.script_lines.map((line) => (line == null ? "" : String(line))).join("\n") : ""; const script = doc.script ?? doc.content ?? legacyScript; - assembly.script = typeof script === "string" ? script : legacyScript; + if (typeof script === "string") { + const encoding = (doc.script_encoding || doc.scriptEncoding || "").toLowerCase(); + if (["base64", "b64", "base-64"].includes(encoding)) { + const decoded = decodeBase64String(script); + assembly.script = decoded.success ? decoded.value : ""; + } else if (!encoding) { + const decoded = decodeBase64String(script); + assembly.script = decoded.success ? decoded.value : script; + } else { + assembly.script = script; + } + } else { + assembly.script = legacyScript; + } const timeout = doc.timeout_seconds ?? doc.timeout ?? assembly.timeoutSeconds; assembly.timeoutSeconds = Number.isFinite(Number(timeout)) ? Number(timeout) @@ -241,13 +309,15 @@ function toServerDocument(assembly) { : ""; const timeoutNumeric = Number(assembly.timeoutSeconds); const timeoutSeconds = Number.isFinite(timeoutNumeric) ? Math.max(0, Math.round(timeoutNumeric)) : 3600; + const encodedScript = encodeBase64String(normalizedScript); return { version: 1, name: assembly.name?.trim() || "", description: assembly.description || "", category: assembly.category || "script", type: assembly.type || "powershell", - script: normalizedScript, + script: encodedScript, + script_encoding: "base64", timeout_seconds: timeoutSeconds, sites: { mode: assembly.sites?.mode === "specific" ? "specific" : "all", diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index bfe6ff6..e02e093 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -2,6 +2,7 @@ import os import time import json import os +import base64 import re import sqlite3 from typing import Any, Dict, List, Optional, Tuple, Callable @@ -33,6 +34,53 @@ def _env_string(value: Any) -> str: return str(value) +def _decode_base64_text(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped: + return "" + try: + cleaned = re.sub(r"\s+", "", stripped) + except Exception: + cleaned = stripped + try: + decoded = base64.b64decode(cleaned, validate=True) + except Exception: + return None + try: + return decoded.decode("utf-8") + except Exception: + return decoded.decode("utf-8", errors="replace") + + +def _decode_script_content(value: Any, encoding_hint: str = "") -> str: + encoding = (encoding_hint or "").strip().lower() + if isinstance(value, str): + if encoding in ("base64", "b64", "base-64"): + decoded = _decode_base64_text(value) + if decoded is not None: + return decoded.replace("\r\n", "\n") + decoded = _decode_base64_text(value) + if decoded is not None: + return decoded.replace("\r\n", "\n") + return value.replace("\r\n", "\n") + return "" + + +def _encode_script_content(script_text: Any) -> str: + if not isinstance(script_text, str): + if script_text is None: + script_text = "" + else: + script_text = str(script_text) + normalized = script_text.replace("\r\n", "\n") + if not normalized: + return "" + encoded = base64.b64encode(normalized.encode("utf-8")) + return encoded.decode("ascii") + + def _canonical_env_key(name: Any) -> str: try: return re.sub(r"[^A-Za-z0-9_]", "_", str(name or "").strip()).upper() @@ -338,6 +386,7 @@ class JobScheduler: if typ in ("powershell", "batch", "bash", "ansible"): doc["type"] = typ script_val = data.get("script") + content_val = data.get("content") script_lines = data.get("script_lines") if isinstance(script_lines, list): try: @@ -347,11 +396,24 @@ class JobScheduler: elif isinstance(script_val, str): doc["script"] = script_val else: - content_val = data.get("content") if isinstance(content_val, str): doc["script"] = content_val - normalized_script = (doc["script"] or "").replace("\r\n", "\n") - doc["script"] = normalized_script + encoding_hint = str(data.get("script_encoding") or data.get("scriptEncoding") or "").strip().lower() + doc["script"] = _decode_script_content(doc.get("script"), encoding_hint) + if encoding_hint in ("base64", "b64", "base-64"): + doc["script_encoding"] = "base64" + else: + probe_source = "" + if isinstance(script_val, str) and script_val: + probe_source = script_val + elif isinstance(content_val, str) and content_val: + probe_source = content_val + decoded_probe = _decode_base64_text(probe_source) if probe_source else None + if decoded_probe is not None: + doc["script_encoding"] = "base64" + doc["script"] = decoded_probe.replace("\r\n", "\n") + else: + doc["script_encoding"] = "plain" try: timeout_raw = data.get("timeout_seconds", data.get("timeout")) if timeout_raw is None: @@ -423,6 +485,7 @@ class JobScheduler: return doc = self._load_assembly_document(abs_path, "ansible") content = doc.get("script") or "" + encoded_content = _encode_script_content(content) variables = doc.get("variables") or [] files = doc.get("files") or [] @@ -457,7 +520,8 @@ class JobScheduler: "run_id": uuid.uuid4().hex, "target_hostname": str(hostname), "playbook_name": os.path.basename(abs_path), - "playbook_content": content, + "playbook_content": encoded_content, + "playbook_encoding": "base64", "activity_job_id": act_id, "scheduled_job_id": int(scheduled_job_id), "scheduled_run_id": int(scheduled_run_id), @@ -517,6 +581,7 @@ class JobScheduler: env_map, variables, literal_lookup = _prepare_variable_context(doc_variables, overrides) content = _rewrite_powershell_script(content, literal_lookup) + encoded_content = _encode_script_content(content) timeout_seconds = 0 try: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) @@ -557,7 +622,8 @@ class JobScheduler: "script_type": stype, "script_name": os.path.basename(abs_path), "script_path": path_norm, - "script_content": content, + "script_content": encoded_content, + "script_encoding": "base64", "environment": env_map, "variables": variables, "timeout_seconds": timeout_seconds, diff --git a/Data/Server/server.py b/Data/Server/server.py index 411a89f..2e33415 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -689,6 +689,64 @@ def _empty_assembly_document(default_type: str = "powershell") -> Dict[str, Any] } +def _decode_base64_text(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped: + return "" + try: + cleaned = re.sub(r"\s+", "", stripped) + except Exception: + cleaned = stripped + try: + decoded = base64.b64decode(cleaned, validate=True) + except Exception: + return None + try: + return decoded.decode("utf-8") + except Exception: + return decoded.decode("utf-8", errors="replace") + + +def _decode_script_content(value: Any, encoding_hint: str = "") -> str: + encoding = (encoding_hint or "").strip().lower() + if isinstance(value, str): + if encoding in ("base64", "b64", "base-64"): + decoded = _decode_base64_text(value) + if decoded is not None: + return decoded.replace("\r\n", "\n") + decoded = _decode_base64_text(value) + if decoded is not None: + return decoded.replace("\r\n", "\n") + return value.replace("\r\n", "\n") + return "" + + +def _encode_script_content(script_text: Any) -> str: + if not isinstance(script_text, str): + if script_text is None: + script_text = "" + else: + script_text = str(script_text) + normalized = script_text.replace("\r\n", "\n") + if not normalized: + return "" + encoded = base64.b64encode(normalized.encode("utf-8")) + return encoded.decode("ascii") + + +def _prepare_assembly_storage(doc: Dict[str, Any]) -> Dict[str, Any]: + stored: Dict[str, Any] = {} + for key, value in (doc or {}).items(): + if key == "script": + stored[key] = _encode_script_content(value) + else: + stored[key] = value + stored["script_encoding"] = "base64" + return stored + + def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) -> Dict[str, Any]: doc = _empty_assembly_document(default_type) if not isinstance(obj, dict): @@ -703,6 +761,7 @@ def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) -> if typ in ("powershell", "batch", "bash", "ansible"): doc["type"] = typ script_val = obj.get("script") + content_val = obj.get("content") script_lines = obj.get("script_lines") if isinstance(script_lines, list): try: @@ -712,11 +771,24 @@ def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) -> elif isinstance(script_val, str): doc["script"] = script_val else: - content_val = obj.get("content") if isinstance(content_val, str): doc["script"] = content_val - normalized_script = (doc["script"] or "").replace("\r\n", "\n") - doc["script"] = normalized_script + encoding_hint = str(obj.get("script_encoding") or obj.get("scriptEncoding") or "").strip().lower() + doc["script"] = _decode_script_content(doc.get("script"), encoding_hint) + if encoding_hint in ("base64", "b64", "base-64"): + doc["script_encoding"] = "base64" + else: + probe_source = "" + if isinstance(script_val, str) and script_val: + probe_source = script_val + elif isinstance(content_val, str) and content_val: + probe_source = content_val + decoded_probe = _decode_base64_text(probe_source) if probe_source else None + if decoded_probe is not None: + doc["script_encoding"] = "base64" + doc["script"] = decoded_probe.replace("\r\n", "\n") + else: + doc["script_encoding"] = "plain" timeout_val = obj.get("timeout_seconds", obj.get("timeout")) if timeout_val is not None: try: @@ -853,7 +925,7 @@ def assembly_create(): base_name, ) with open(abs_path, "w", encoding="utf-8") as fh: - json.dump(normalized, fh, indent=2) + json.dump(_prepare_assembly_storage(normalized), fh, indent=2) rel_new = os.path.relpath(abs_path, root).replace(os.sep, "/") return jsonify({"status": "ok", "rel_path": rel_new}) else: @@ -902,7 +974,7 @@ def assembly_edit(): base_name, ) with open(target_abs, "w", encoding="utf-8") as fh: - json.dump(normalized, fh, indent=2) + json.dump(_prepare_assembly_storage(normalized), fh, indent=2) if target_abs != abs_path: try: os.remove(abs_path) @@ -2993,6 +3065,7 @@ def scripts_quick_run(): env_map, variables, literal_lookup = _prepare_variable_context(doc_variables, overrides) content = _rewrite_powershell_script(content, literal_lookup) + encoded_content = _encode_script_content(content) timeout_seconds = 0 try: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) @@ -3034,7 +3107,8 @@ def scripts_quick_run(): "script_type": script_type, "script_name": _safe_filename(rel_path), "script_path": rel_path.replace(os.sep, "/"), - "script_content": content, + "script_content": encoded_content, + "script_encoding": "base64", "environment": env_map, "variables": variables, "timeout_seconds": timeout_seconds, @@ -3070,6 +3144,7 @@ def ansible_quick_run(): return jsonify({"error": "Playbook not found"}), 404 doc = _load_assembly_document(abs_path, 'ansible') content = doc.get('script') or '' + encoded_content = _encode_script_content(content) variables = doc.get('variables') if isinstance(doc.get('variables'), list) else [] files = doc.get('files') if isinstance(doc.get('files'), list) else [] @@ -3112,7 +3187,8 @@ def ansible_quick_run(): "run_id": run_id, "target_hostname": str(host), "playbook_name": os.path.basename(abs_path), - "playbook_content": content, + "playbook_content": encoded_content, + "playbook_encoding": "base64", "connection": "winrm", "variables": variables, "files": files, From 432abdec44bc1564041604b44cf7ac41a13e913b Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 3 Oct 2025 21:28:01 -0600 Subject: [PATCH 8/8] Fix quick job environment helper scope --- Data/Server/server.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Data/Server/server.py b/Data/Server/server.py index 2e33415..2e9afa9 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -2888,6 +2888,14 @@ def _safe_filename(rel_path: str) -> str: return rel_path or "" +def _env_string(value: Any) -> str: + if isinstance(value, bool): + return "True" if value else "False" + if value is None: + return "" + return str(value) + + def _canonical_env_key(name: Any) -> str: try: return re.sub(r"[^A-Za-z0-9_]", "_", str(name or "").strip()).upper() @@ -3047,13 +3055,6 @@ def scripts_quick_run(): content = doc.get("script") or "" doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else [] - def _env_string(value: Any) -> str: - if isinstance(value, bool): - return "True" if value else "False" - if value is None: - return "" - return str(value) - overrides_raw = data.get("variable_values") overrides: Dict[str, Any] = {} if isinstance(overrides_raw, dict):