Merge wilm into wj
Brings in wilm commits: - Render script surfaces correctly in dark mode - Stop scripts reporting success after doing nothing - Correct two gate commands that pass without running - Gate the script library on the grant that pushes scripts - Let the policy list edit the rows the API accepts - Cap plugin revision ids at the 32 chars MySQL stores - Split shipped monitor bundle into per-monitor policies Resolved conflict in agent ShopDbEndpointAgent.csproj by merging: - wilm 1.14.0 script verdict and output capture features - wj 1.13.1-1.13.5 tray coordination releases - Bumped to 1.14.1 for coordinated agent+tray release
This commit is contained in:
@@ -465,6 +465,9 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
connections with a `truncated` flag in `detail`, because an oversized payload
|
||||
rejected by the server returns nothing at all and a partial answer is more
|
||||
useful than a failed one. Standard reports carry listening ports only.
|
||||
- `run_script` - run a reviewed PowerShell script from the script library. The
|
||||
body is NOT carried on the command channel; the claim carries a descriptor and
|
||||
the agent fetches the bytes separately (section 7.4).
|
||||
|
||||
### 7.1 Claim the next command (long-poll)
|
||||
|
||||
@@ -509,3 +512,69 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
gated by user JWT + `endpoints.command`. Only one command may be in flight per
|
||||
endpoint (a second enqueue returns HTTP 409 until the first finalizes).
|
||||
|
||||
### 7.4 `run_script` claim descriptor
|
||||
|
||||
A `run_script` claim carries a `script` object alongside the usual
|
||||
`endpointcommandid` and `action`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"scriptid": 12,
|
||||
"scriptname": "sfld_autologon",
|
||||
"installpath": "/api/endpoints/scriptbody/12", // fetch with X-API-Key
|
||||
"sha256": "3f9a...", // 64 hex chars, lowercase
|
||||
"runcontext": "system", // "system" | "user"
|
||||
"workingdirectory": "C:\\Tools", // "" = agent staging folder
|
||||
"errormode": "default" // "default" | "strict"
|
||||
}
|
||||
```
|
||||
|
||||
The agent MUST download `installpath`, verify the SHA-256 of the received bytes
|
||||
against `sha256`, and refuse to run on mismatch. The body never travels on the
|
||||
command channel so a queued command cannot be used to smuggle code past review.
|
||||
|
||||
`workingdirectory` is the folder the script runs from. Empty means the agent's
|
||||
staging directory, which is correct for a self-contained script and wrong for
|
||||
one invoking a separately-installed tool by relative path. A pinned folder that
|
||||
does not exist MUST fail the command rather than falling back, so the script
|
||||
never runs somewhere unintended.
|
||||
|
||||
`errormode` selects how the run is judged:
|
||||
|
||||
- `default` - PowerShell's normal behaviour. Most errors are non-terminating:
|
||||
the script writes the error, continues, and still exits zero.
|
||||
- `strict` - the agent prepends `$ErrorActionPreference = 'Stop'` so the first
|
||||
error terminates the run and the exit code reflects reality.
|
||||
|
||||
`default` is the default precisely BECAUSE it is permissive. Every script
|
||||
authored before this field existed relies on that behaviour, and some rely on it
|
||||
deliberately - tolerating a failing cleanup, probing with a cmdlet that errors
|
||||
when a thing is absent, looping past a bad item. Promoting `strict` globally
|
||||
would make those bodies abort partway and leave endpoints half-configured, so it
|
||||
is opt-in per script.
|
||||
|
||||
Two consequences the agent MUST honour:
|
||||
|
||||
- The strict prologue is applied AFTER digest verification, so the hash still
|
||||
validates the bytes the server actually delivered.
|
||||
- Because the prologue adds a line, an error reported on line N of a strict run
|
||||
refers to line N-1 of the stored body. Surfaces showing the body should say so.
|
||||
|
||||
An agent that does not recognise an `errormode` value MUST treat it as
|
||||
`default`. The server always sends the field explicitly, even at the default, so
|
||||
an agent that received it and ignored it stays distinguishable from one that
|
||||
never saw it.
|
||||
|
||||
#### Script output
|
||||
|
||||
Script stdout/stderr is ALWAYS written to the local agent log on the endpoint.
|
||||
It is returned to the server in `detail.output` only when the command FAILED.
|
||||
Routine output stays on the machine that produced it: shipping every successful
|
||||
run back would put arbitrary script output, including whatever an author printed
|
||||
while debugging, into the server's command history for no diagnostic gain.
|
||||
Output is truncated to 8000 characters and flagged when truncated, because an
|
||||
oversized payload rejected by the server returns nothing at all.
|
||||
|
||||
Timed-out runs do not carry output: the script is still running and still holds
|
||||
the capture file open, so anything read at that moment would be misleading.
|
||||
|
||||
|
||||
@@ -2072,9 +2072,15 @@ def _resolve_run_script(target):
|
||||
"""Resolve a run_script target to a script delivery descriptor.
|
||||
|
||||
Accepts either "script:<scriptid>" or a bare script name. The descriptor
|
||||
carries a collector-authenticated download URL, the body's SHA-256, and the
|
||||
per-script run context (system or user). The agent fetches the body with
|
||||
its collector credential and runs it; the body never crosses the wire.
|
||||
carries a collector-authenticated download URL, the body's SHA-256, the
|
||||
per-script run context (system or user), and the optional folder to run in.
|
||||
The agent fetches the body with its credential and runs it; the body never
|
||||
crosses the wire.
|
||||
|
||||
workingdirectory is sent even when empty so the field is always present in
|
||||
the claim: an agent that receives it and ignores it is then distinguishable
|
||||
from one that never saw it, which matters while agents of both vintages are
|
||||
in the field. errormode rides the same rule, and for the same reason.
|
||||
"""
|
||||
kind, _sep, raw = str(target or '').partition(':')
|
||||
script = None
|
||||
@@ -2102,6 +2108,8 @@ def _resolve_run_script(target):
|
||||
'installpath': '/api/endpoints/scriptbody/{}'.format(script.scriptid),
|
||||
'sha256': script.sha256,
|
||||
'runcontext': script.runcontext,
|
||||
'workingdirectory': script.workingdirectory or '',
|
||||
'errormode': script.errormode or 'default',
|
||||
}, None
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,20 @@ export default [
|
||||
name: 'endpoints-monitors-settings',
|
||||
component: () => import('./views/MonitorsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
// requiresPermission, not requiresAdmin: endpoints.runscript is grantable to
|
||||
// non-admin operators, and the sibling pages' admin gate would lock the
|
||||
// holders of the grant out of the library they are trusted to manage. The
|
||||
// API enforces the same permission, so this only keeps the menu honest.
|
||||
{
|
||||
path: 'settings/endpoints/scripts',
|
||||
name: 'endpoints-scripts-settings',
|
||||
component: () => import('./views/SystemScriptsSettings.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresPermission: 'endpoints.runscript',
|
||||
plugin: 'endpoints'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
412
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
412
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>System Scripts</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Script</button>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
PowerShell the site agent can be told to run on an endpoint. A script is
|
||||
stored here once, pinned by digest, and fetched by the agent at push time
|
||||
so the bytes that run are the bytes that were reviewed.
|
||||
</p>
|
||||
|
||||
<div class="warning-banner">
|
||||
<strong>Every script here runs as SYSTEM.</strong>
|
||||
There is no user context and no prompt: the agent is a service, so a
|
||||
script cannot see a signed-in person's desktop, mapped drives, or
|
||||
credentials. Scripts are intended to TRIGGER an already registered
|
||||
scheduled task on the endpoint, which is what does the real work. Anything
|
||||
written to act "as the logged-on user" will not behave the way it reads.
|
||||
</div>
|
||||
|
||||
<details class="explainer">
|
||||
<summary>How does this work?</summary>
|
||||
<div class="explainer-body">
|
||||
<h4>Delivery</h4>
|
||||
<p>
|
||||
The body is stored here and hashed. At push time the agent downloads it
|
||||
with its own credential and checks the digest before running anything,
|
||||
so the bytes that run are the bytes that were reviewed. The body never
|
||||
travels on the command channel and is deleted from the endpoint after
|
||||
the run.
|
||||
</p>
|
||||
|
||||
<h4>Runs in</h4>
|
||||
<p>
|
||||
<strong>System</strong> is the agent's own service account: no desktop,
|
||||
no mapped drives, no signed-in person's credentials.
|
||||
<strong>User</strong> runs in the interactive session instead, and only
|
||||
works when somebody is actually signed in.
|
||||
</p>
|
||||
|
||||
<h4>Run in folder</h4>
|
||||
<p>
|
||||
The working directory for the run. Blank means the agent's staging
|
||||
folder, which is right for a self-contained script. Set it when the
|
||||
script calls a tool installed separately on the endpoint by relative
|
||||
path. If the folder is missing the run fails outright rather than
|
||||
running somewhere unintended.
|
||||
</p>
|
||||
|
||||
<h4>When a line fails</h4>
|
||||
<p>
|
||||
This is the one worth understanding. PowerShell treats most errors as
|
||||
<em>non-terminating</em>: it writes the error, carries on to the next
|
||||
line, and still exits zero. Because the command's verdict comes from
|
||||
that exit code, <strong>Continue</strong> means a script can report
|
||||
success having accomplished nothing.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Stop</strong> makes the first error end the run, so the verdict
|
||||
means what you would assume it means. It is not the default because it
|
||||
changes how a script behaves: a body that tolerates a failing cleanup,
|
||||
probes with a cmdlet that errors when something is absent, or loops
|
||||
past bad items will start aborting partway through, leaving the
|
||||
endpoint half-configured. Read the script first, then switch it on.
|
||||
</p>
|
||||
|
||||
<h4>Where the output goes</h4>
|
||||
<p>
|
||||
Script output is always written to the agent log on the endpoint. It is
|
||||
sent back to ShopDB only when the run fails, so routine output stays on
|
||||
the machine that produced it. Long output is truncated and marked as
|
||||
such.
|
||||
</p>
|
||||
<p class="hint">
|
||||
In Stop mode the agent adds one line to the top of the script, so an
|
||||
error reported on line N refers to line N-1 of the body shown here.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Runs in</th>
|
||||
<th>On error</th>
|
||||
<th>Body</th>
|
||||
<th>Digest</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in scripts" :key="s.scriptid">
|
||||
<td>{{ s.scriptname }}</td>
|
||||
<td class="cell-truncate" :title="s.scriptdescription">
|
||||
{{ s.scriptdescription || '-' }}
|
||||
</td>
|
||||
<td class="cell-truncate mono" :title="s.workingdirectory">
|
||||
{{ s.workingdirectory || 'Agent staging folder' }}
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
|
||||
Stop on first error
|
||||
</span>
|
||||
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
|
||||
Continue
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
||||
<span v-else class="badge badge-warning">No body - cannot run</span>
|
||||
</td>
|
||||
<td class="cell-truncate mono" :title="s.sha256">{{ shortdigest(s) }}</td>
|
||||
<td>{{ s.isactive ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(s)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="remove(s)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="scripts.length === 0">
|
||||
<td colspan="8" style="text-align:center; color: var(--text-light);">
|
||||
No scripts
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Script' : 'Add Script' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="warning-banner">
|
||||
<strong>This script will run as SYSTEM on every endpoint it is
|
||||
pushed to.</strong>
|
||||
Write it to trigger a scheduled task that already exists on the
|
||||
endpoint. It has no user session, so anything depending on a
|
||||
person being signed in will not work.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="form.scriptname" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description <span class="hint">(what it triggers, and why)</span></label>
|
||||
<textarea v-model="form.scriptdescription" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Script file <span class="hint">(.ps1, replaces the stored body)</span>
|
||||
</label>
|
||||
<input type="file" accept=".ps1" class="form-control" @change="pickfile" />
|
||||
<p v-if="editing && editing.hasbody" class="hint">
|
||||
Current: {{ editing.filename }}. Uploading replaces it and
|
||||
restamps the digest.
|
||||
</p>
|
||||
<p v-else-if="editing" class="hint">
|
||||
No body uploaded yet. A script without a body cannot be pushed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Run in folder
|
||||
<span class="hint">(optional, endpoint-local absolute path)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.workingdirectory"
|
||||
type="text"
|
||||
class="form-control mono"
|
||||
placeholder="C:\Program Files\CorporateTools"
|
||||
/>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
Leave blank to run from the agent's staging folder. Set this only
|
||||
when the script calls a tool already installed on the endpoint by
|
||||
relative path. The folder must exist on the endpoint or the push
|
||||
fails rather than running somewhere else.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
When a line fails
|
||||
<span class="hint">(how the run is judged)</span>
|
||||
</label>
|
||||
<select v-model="form.errormode" class="form-control">
|
||||
<option value="default">Continue - report success if the script reaches the end</option>
|
||||
<option value="strict">Stop on the first error and fail the command</option>
|
||||
</select>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
PowerShell normally writes an error and carries on, and still
|
||||
exits zero, so a script can report success having done none of
|
||||
its work. Stop makes the first failure end the run and fail the
|
||||
command. Leave this on Continue unless you have read the script
|
||||
and know it does not rely on a step being allowed to fail.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { scriptsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const scripts = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const pendingfile = ref(null)
|
||||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
scriptname: '',
|
||||
scriptdescription: '',
|
||||
workingdirectory: '',
|
||||
errormode: 'default',
|
||||
isactive: true
|
||||
}
|
||||
}
|
||||
|
||||
function shortdigest(s) {
|
||||
return s.sha256 ? s.sha256.slice(0, 12) : '-'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await scriptsApi.list({ active: 'false' })
|
||||
scripts.value = response.data.data || []
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to load scripts'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickfile(event) {
|
||||
pendingfile.value = event.target.files[0] || null
|
||||
}
|
||||
|
||||
function openModal(script = null) {
|
||||
error.value = ''
|
||||
pendingfile.value = null
|
||||
editing.value = script
|
||||
form.value = script
|
||||
? {
|
||||
scriptname: script.scriptname,
|
||||
scriptdescription: script.scriptdescription || '',
|
||||
workingdirectory: script.workingdirectory || '',
|
||||
errormode: script.errormode || 'default',
|
||||
isactive: script.isactive
|
||||
}
|
||||
: emptyForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
pendingfile.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
scriptname: form.value.scriptname,
|
||||
scriptdescription: form.value.scriptdescription,
|
||||
workingdirectory: form.value.workingdirectory,
|
||||
errormode: form.value.errormode,
|
||||
isactive: form.value.isactive
|
||||
}
|
||||
let scriptid
|
||||
if (editing.value) {
|
||||
await scriptsApi.update(editing.value.scriptid, payload)
|
||||
scriptid = editing.value.scriptid
|
||||
} else {
|
||||
const created = await scriptsApi.create(payload)
|
||||
scriptid = created.data.data.scriptid
|
||||
}
|
||||
// Second call on purpose: the body is multipart and the catalog row is
|
||||
// JSON. A failure here leaves a bodyless row, which the list marks as
|
||||
// unrunnable rather than pretending it is ready to push.
|
||||
if (pendingfile.value) {
|
||||
await scriptsApi.uploadBody(scriptid, pendingfile.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save script')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s) {
|
||||
if (!confirm(`Delete script "${s.scriptname}"?`)) return
|
||||
try {
|
||||
await scriptsApi.delete(s.scriptid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cell-truncate {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mono { font-family: monospace; }
|
||||
.hint { color: var(--text-light); font-weight: normal; }
|
||||
.warning-banner {
|
||||
border: 1px solid var(--warning);
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(184, 134, 11, 0.08);
|
||||
}
|
||||
.explainer {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
}
|
||||
.explainer summary {
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.explainer-body {
|
||||
padding: 0 1rem 0.75rem;
|
||||
}
|
||||
.explainer-body h4 {
|
||||
margin: 0.9rem 0 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.explainer-body p {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text-light);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field-help {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.field-help summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.field-help p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
124
plugins/endpoints/frontend/views/systemScriptsSettings.spec.js
Normal file
124
plugins/endpoints/frontend/views/systemScriptsSettings.spec.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* System Scripts settings page.
|
||||
*
|
||||
* Two things are worth pinning here and nothing else is:
|
||||
*
|
||||
* 1. The page must NOT offer a run context. Scripts run as SYSTEM because the
|
||||
* agent is a service with no user session; exposing a picker would let
|
||||
* someone author a script that reads as "run as the logged-on user" and
|
||||
* silently does not.
|
||||
* 2. The SYSTEM warning must be present on both the list and the add/edit
|
||||
* form. Someone pasting a script into the modal may never have read the
|
||||
* page copy above it.
|
||||
*
|
||||
* Asserted against the file text, matching configurationPolicyEditable.spec.js.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const source = readFileSync(join(here, 'SystemScriptsSettings.vue'), 'utf8')
|
||||
const routes = readFileSync(join(here, '..', 'routes.js'), 'utf8')
|
||||
|
||||
describe('SystemScriptsSettings', () => {
|
||||
it('warns that scripts run as SYSTEM on the list page and in the form', () => {
|
||||
const warnings = source.match(/runs? as SYSTEM/g) || []
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('does not offer a run context picker', () => {
|
||||
expect(source).not.toMatch(/runcontext/i)
|
||||
})
|
||||
|
||||
it('does not send a runcontext in its save payload', () => {
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 200)).not.toMatch(/runcontext/i)
|
||||
})
|
||||
|
||||
it('flags a script with no body as unrunnable', () => {
|
||||
expect(source).toMatch(/hasbody/)
|
||||
expect(source).toMatch(/cannot run/i)
|
||||
})
|
||||
|
||||
it('is gated on the standalone script permission, not admin', () => {
|
||||
const route = routes.slice(routes.indexOf("'settings/endpoints/scripts'"))
|
||||
expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/)
|
||||
expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/)
|
||||
})
|
||||
|
||||
it('offers an optional working directory and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.workingdirectory/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/workingdirectory/)
|
||||
})
|
||||
|
||||
it('shows the staging default rather than an empty cell', () => {
|
||||
// A blank cell reads as "unknown". The folder is the difference between a
|
||||
// corporate tool being found and the script failing further in, so the
|
||||
// list has to state the default out loud.
|
||||
expect(source).toMatch(/Agent staging folder/)
|
||||
})
|
||||
|
||||
it('offers an error mode and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.errormode/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/errormode/)
|
||||
})
|
||||
|
||||
it('defaults a new script to the permissive mode', () => {
|
||||
// Strict changes how a body behaves partway through. Defaulting to it
|
||||
// would silently alter every script authored from here on, so a new
|
||||
// script has to start where the old ones already are.
|
||||
const empty = source.slice(source.indexOf('function emptyForm'))
|
||||
expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/)
|
||||
})
|
||||
|
||||
it('names both error modes by their consequence, not by their flag', () => {
|
||||
// "default" and "strict" mean nothing to the person choosing. What they
|
||||
// need to know is which one can report success having done nothing.
|
||||
expect(source).toMatch(/Continue - report success if the script reaches the end/)
|
||||
expect(source).toMatch(/Stop on the first error and fail the command/)
|
||||
})
|
||||
|
||||
it('explains the non-terminating error trap that justifies the choice', () => {
|
||||
// The whole feature exists because PowerShell exits zero after an error.
|
||||
// If the page never says so, the dropdown looks like pedantry and stays
|
||||
// on the setting that produces false green.
|
||||
expect(source).toMatch(/non-terminating/i)
|
||||
expect(source).toMatch(/exits? zero/i)
|
||||
})
|
||||
|
||||
it('offers the explainer collapsed so it does not bury the page', () => {
|
||||
expect(source).toMatch(/<details class="explainer">/)
|
||||
expect(source).toMatch(/How does this work\?/)
|
||||
expect(source).not.toMatch(/<details class="explainer" open>/)
|
||||
})
|
||||
|
||||
it('collapses the long per-field prose in the form', () => {
|
||||
// The modal is already tall enough to scroll. Three paragraphs of standing
|
||||
// explanation between the inputs pushes Save off screen and gets skimmed
|
||||
// past, which is the opposite of what the copy is for.
|
||||
const helps = source.match(/<details class="field-help">/g) || []
|
||||
expect(helps.length).toBeGreaterThanOrEqual(2)
|
||||
expect(source).toMatch(/<summary>How this works<\/summary>/)
|
||||
})
|
||||
|
||||
it('styles itself from defined theme tokens only', () => {
|
||||
// --surface and --text-primary are not defined by this app's theme, so a
|
||||
// var() fallback silently rendered a white panel on the dark background.
|
||||
// A fallback colour is the bug: it looks correct in light mode and only
|
||||
// shows up as a bright block for dark-mode users.
|
||||
const styles = source.slice(source.indexOf('<style scoped>'))
|
||||
expect(styles).not.toMatch(/--surface|--text-primary|--text-secondary|--bg-secondary/)
|
||||
expect(styles).not.toMatch(/var\(--[a-z-]+,\s*#/)
|
||||
})
|
||||
|
||||
it('states that output is kept locally and only returned on failure', () => {
|
||||
// Operators otherwise assume either that nothing was captured or that
|
||||
// every run is shipped back to the server.
|
||||
const explainer = source.slice(source.indexOf('Where the output goes'))
|
||||
expect(explainer.slice(0, 400)).toMatch(/only when the run fails/i)
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,9 @@ so the catalog matches what a deployed agent already watches:
|
||||
process agent.exe -> MTConnect
|
||||
service MTConnect UDC Agent -> MTConnect
|
||||
|
||||
Shipped rows are product-owned (source='shipped'): the CRUD API refuses to edit
|
||||
or delete them. Launch paths mirror the agent's launchpaths block; collector and
|
||||
Shipped rows (source='shipped') keep their kind and displayname - that pair is
|
||||
the identity the monitor policies match on - but a site may edit their paths and
|
||||
active flag. Launch paths mirror the agent's launchpaths block; collector and
|
||||
log paths are left null until site paths are supplied.
|
||||
|
||||
Idempotent: creation checks the live schema first so a db.create_all() site
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"""Ship the shopfloor app monitors as a read-only Site Policy.
|
||||
"""Ship the shopfloor app monitors as a Site Policy bundle.
|
||||
|
||||
SUPERSEDED BY 0019. This migration is kept because installs have stamped it, but
|
||||
what it seeds no longer survives an upgrade: 0019 splits this single bundle into
|
||||
one Configuration Policy per monitor and REMOVES the shipped Site Policy, so
|
||||
sites compose their own. Read this for history; do not extend it.
|
||||
|
||||
Two claims below were true when written and are now false, corrected here rather
|
||||
than left to mislead: the seeded rows are NOT read-only (0019 admits 'shipped'
|
||||
at the policy CRUD guards), and no seeder reconciles them - the idempotency
|
||||
check at the top of upgrade() returns early if the policy exists, which is what
|
||||
keeps a site's edits safe across upgrades.
|
||||
|
||||
Wraps the four shipped endpointmonitors (eDNC, Universal Data Collector,
|
||||
MTConnect process, MTConnect UDC Agent service) in a product-owned policy bundle
|
||||
so the agent stops carrying them as hardcoded appsettings.json defaults and reads
|
||||
its watch-list from the server instead.
|
||||
MTConnect process, MTConnect UDC Agent service) in a policy bundle so the agent
|
||||
stops carrying them as hardcoded appsettings.json defaults and reads its
|
||||
watch-list from the server instead.
|
||||
|
||||
Seeds two computers-plugin rows, both source='shipped' (read-only in the UI, the
|
||||
CRUD API refuses to edit or delete them):
|
||||
Seeds two computers-plugin rows, both source='shipped':
|
||||
|
||||
ConfigurationPolicy "Shopfloor App Monitors"
|
||||
scope {"type":"true"} (every endpoint), one action per shipped monitor,
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Split the shipped monitor bundle into one policy per monitor.
|
||||
|
||||
0007 seeded a single Configuration Policy "Shopfloor App Monitors" carrying all
|
||||
four shipped monitor actions, wrapped in a shipped Site (business) Policy of the
|
||||
same name. That bundle was all-or-nothing: a site that runs eDNC but no
|
||||
MTConnect could not detach the half it does not have, because the whole row was
|
||||
product-owned and read-only.
|
||||
|
||||
This migration replaces it with one Configuration Policy per shipped monitor:
|
||||
|
||||
Monitor: eDNC (process)
|
||||
Monitor: Universal Data Collector (process)
|
||||
Monitor: MTConnect (process)
|
||||
Monitor: MTConnect (service)
|
||||
|
||||
Each carries exactly one 'monitor:<endpointmonitorid>' action, mode='gate',
|
||||
scope {"type":"true"} - no PC type attribution. The name carries the kind
|
||||
because displayname alone is not unique: MTConnect ships as both a process and
|
||||
a service, and configurationpolicies.name is UNIQUE.
|
||||
|
||||
source stays 'shipped'. Provenance is still true - the product did ship these -
|
||||
and the UI keeps showing where they came from. What changes is that the CRUD
|
||||
guards now admit 'shipped' for edit/delete, so a site can retune or drop one
|
||||
without losing the record of its origin.
|
||||
|
||||
The shipped SITE policy is REMOVED and not replaced. Composition is a site
|
||||
decision: which monitors apply to which endpoints, and at what priority, is
|
||||
exactly the judgement a plant makes about its own floor. Shipping one meant
|
||||
every install inherited "apply all four to every endpoint" as a starting point
|
||||
that could not be edited. Sites now build their own from the per-monitor pieces.
|
||||
|
||||
Removing it is the behaviour change to be aware of on upgrade: monitors that
|
||||
were attached through that bundle stop being attached until a site policy
|
||||
composes them. It is deliberate and it is the point of the split - see the
|
||||
"off by default" direction in the 2026-09-08 plan - but it is not silent, since
|
||||
the per-monitor policies are listed and ready to compose.
|
||||
|
||||
Idempotent on the policy names, so a re-run neither duplicates the split rows
|
||||
nor resurrects the bundle. Guards on the policy tables being present and no-ops
|
||||
if they are not, matching 0007.
|
||||
|
||||
Revision ID: endpoints0019splitpolicies
|
||||
Revises: endpoints0018endpointlocks
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Alembic stores this in alembic_version.version_num, a VARCHAR(32). A longer
|
||||
# id passes on SQLite and fails on strict MySQL with "Data too long".
|
||||
revision = 'endpoints0019splitpolicies'
|
||||
down_revision = 'endpoints0018endpointlocks'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
BUNDLE_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
# The shipped monitors, by identity (kind, displayname) - never by hardcoded id,
|
||||
# so this survives id drift across installs exactly as 0007 does.
|
||||
_SHIPPED_MONITORS = (
|
||||
('process', 'eDNC'),
|
||||
('process', 'Universal Data Collector'),
|
||||
('process', 'MTConnect'),
|
||||
('service', 'MTConnect'),
|
||||
)
|
||||
|
||||
_REQUIRED = {'endpointmonitors', 'configurationpolicies',
|
||||
'configurationpolicyactions', 'businesspolicies',
|
||||
'businesspolicyitems'}
|
||||
|
||||
|
||||
def _tables(bind):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return set(inspect(bind).get_table_names())
|
||||
|
||||
|
||||
def _policy_name(kind, displayname):
|
||||
return f'Monitor: {displayname} ({kind})'
|
||||
|
||||
|
||||
def _policy_description(kind, displayname):
|
||||
noun = 'process' if kind == 'process' else 'service'
|
||||
return (f'Product-shipped watch-list entry for the {displayname} {noun}. '
|
||||
f'Attach it to a Site Policy to apply it to endpoints.')
|
||||
|
||||
|
||||
def _config_policy_id(bind, name):
|
||||
return bind.execute(
|
||||
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': name}).scalar()
|
||||
|
||||
|
||||
def _drop_config_policy(bind, name):
|
||||
"""Delete a configuration policy, its actions, and any items citing it."""
|
||||
policyid = _config_policy_id(bind, name)
|
||||
if policyid is None:
|
||||
return
|
||||
# Clear children explicitly rather than lean on ON DELETE CASCADE, which
|
||||
# sqlite does not enforce by default.
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicyitems '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicyactions '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicies '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
|
||||
|
||||
def _drop_business_policy(bind, name):
|
||||
policyid = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name AND source = :source'),
|
||||
{'name': name, 'source': 'shipped'}).scalar()
|
||||
if policyid is None:
|
||||
return
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicyitems WHERE businesspolicyid = :id'),
|
||||
{'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
|
||||
{'id': policyid})
|
||||
|
||||
|
||||
def _monitor_id(bind, kind, displayname):
|
||||
return bind.execute(
|
||||
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
|
||||
'WHERE kind = :kind AND displayname = :displayname'),
|
||||
{'kind': kind, 'displayname': displayname}).scalar()
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _REQUIRED.issubset(_tables(bind)):
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
config_policies = sa.table(
|
||||
'configurationpolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('scopejson', sa.Text),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
actions = sa.table(
|
||||
'configurationpolicyactions',
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('action', sa.String),
|
||||
sa.column('mode', sa.String),
|
||||
sa.column('desiredstate', sa.Text),
|
||||
sa.column('params', sa.Text),
|
||||
sa.column('intervalminutes', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
name = _policy_name(kind, displayname)
|
||||
if _config_policy_id(bind, name) is not None:
|
||||
continue
|
||||
monitorid = _monitor_id(bind, kind, displayname)
|
||||
if monitorid is None:
|
||||
# A site that never had this monitor gets no policy for it.
|
||||
continue
|
||||
|
||||
op.bulk_insert(config_policies, [{
|
||||
'name': name,
|
||||
'description': _policy_description(kind, displayname),
|
||||
'scopejson': '{"type": "true"}',
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
op.bulk_insert(actions, [{
|
||||
'configurationpolicyid': _config_policy_id(bind, name),
|
||||
'action': f'monitor:{monitorid}',
|
||||
'mode': 'gate',
|
||||
'desiredstate': None,
|
||||
'params': None,
|
||||
'intervalminutes': None,
|
||||
'sortorder': 0,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
|
||||
# Retire the monolith. The business policy goes first so its item rows are
|
||||
# gone before the configuration policy it cited is removed.
|
||||
_drop_business_policy(bind, BUNDLE_NAME)
|
||||
_drop_config_policy(bind, BUNDLE_NAME)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Reinstate the single shipped bundle and drop the per-monitor policies.
|
||||
|
||||
Rebuilt from the same monitor identities rather than from anything the
|
||||
upgrade stashed, so it lands in the state 0007 produces regardless of what
|
||||
a site did to the split rows in between.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
if not _REQUIRED.issubset(_tables(bind)):
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
monitor_ids = []
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
monitorid = _monitor_id(bind, kind, displayname)
|
||||
if monitorid is not None:
|
||||
monitor_ids.append(monitorid)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
_drop_config_policy(bind, _policy_name(kind, displayname))
|
||||
|
||||
if not monitor_ids or _config_policy_id(bind, BUNDLE_NAME) is not None:
|
||||
return
|
||||
|
||||
config_policies = sa.table(
|
||||
'configurationpolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('scopejson', sa.Text),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(config_policies, [{
|
||||
'name': BUNDLE_NAME,
|
||||
'description': 'Product-shipped watch-list for the shopfloor apps '
|
||||
'(eDNC, Universal Data Collector, MTConnect). Read-only.',
|
||||
'scopejson': '{"type": "true"}',
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
config_policy_id = _config_policy_id(bind, BUNDLE_NAME)
|
||||
|
||||
actions = sa.table(
|
||||
'configurationpolicyactions',
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('action', sa.String),
|
||||
sa.column('mode', sa.String),
|
||||
sa.column('desiredstate', sa.Text),
|
||||
sa.column('params', sa.Text),
|
||||
sa.column('intervalminutes', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(actions, [{
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'action': f'monitor:{monitorid}',
|
||||
'mode': 'gate',
|
||||
'desiredstate': None,
|
||||
'params': None,
|
||||
'intervalminutes': None,
|
||||
'sortorder': index,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
} for index, monitorid in enumerate(monitor_ids)])
|
||||
|
||||
business_policies = sa.table(
|
||||
'businesspolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('priority', sa.Integer),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(business_policies, [{
|
||||
'name': BUNDLE_NAME,
|
||||
'description': 'Product-shipped Site Policy that applies the shopfloor '
|
||||
'app monitors to every endpoint. Read-only.',
|
||||
'priority': 100,
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
business_policy_id = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
|
||||
|
||||
items = sa.table(
|
||||
'businesspolicyitems',
|
||||
sa.column('businesspolicyid', sa.Integer),
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(items, [{
|
||||
'businesspolicyid': business_policy_id,
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'sortorder': 0,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
@@ -320,6 +320,21 @@ class EndpointsPlugin(BasePlugin):
|
||||
'agent, and configure what it reports.',
|
||||
'position': 27,
|
||||
},
|
||||
# Its own card rather than a section of the agent key page: the
|
||||
# audience differs. Key management is admin-only, while the script
|
||||
# library is reachable by any endpoints.runscript holder, and
|
||||
# burying it under an admin page would hide it from exactly the
|
||||
# operators the grant exists for.
|
||||
{
|
||||
'group': 'Endpoints',
|
||||
'to': '/settings/endpoints/scripts',
|
||||
'icon': 'terminal',
|
||||
'title': 'System Scripts',
|
||||
'description': 'PowerShell the agent can run on an endpoint. '
|
||||
'Runs as SYSTEM; used to trigger scheduled '
|
||||
'tasks already registered on the endpoint.',
|
||||
'position': 28,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
|
||||
Reference in New Issue
Block a user