Stop scripts reporting success after doing nothing
A run_script command was judged solely on PowerShell's exit code, and PowerShell treats most errors as non-terminating: it writes the error, runs the next line, and still exits zero. The account-provisioning script reported succeeded having provisioned nothing, and because no output was captured there was no evidence to contradict the verdict. Scripts now carry an errormode. Strict prepends $ErrorActionPreference = 'Stop' so the first error ends the run and the exit code means what it says. It is opt-in per script rather than global because bodies authored against the permissive semantics rely on them - tolerating a failing cleanup, probing with a cmdlet that errors when a thing is absent, looping past a bad item - and would start aborting midway, leaving endpoints half-configured. The default is unchanged, so every existing script keeps running exactly as before. The prologue is applied after digest verification, so the SHA-256 still validates the bytes the server delivered. stdout/stderr is captured on both paths: redirected streams for SYSTEM, a transcript file under the ShopFloor profile for user-session runs, since CreateProcessAsUser cannot inherit the service's pipes across the session boundary. Output is always logged locally and returned to the server only on failure, so routine output stays on the machine that produced it instead of filling command history. Also adds the optional per-script working directory, which the same surfaces needed, and a collapsible explainer on the scripts page - the dropdown is not self-explanatory, and an operator who does not know about non-terminating errors has no reason to ever leave the default. Agent 1.14.0. An older agent ignoring errormode keeps today's behaviour.
This commit is contained in:
@@ -512,6 +512,90 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run Scripts: pushes a ShopDB-hosted script from the library. Distinct
|
||||
from Corporate Tools above - that starts a task the endpoint already
|
||||
approved, this delivers script content ShopDB holds. Separate grant
|
||||
(endpoints.runscript), separate tab, so neither implies the other. -->
|
||||
<div v-show="activegroup === 'runscripts'" class="detail-full">
|
||||
<div class="card">
|
||||
<h3>Run Scripts</h3>
|
||||
<p class="corptools-intro">
|
||||
Sends a script from the ShopDB script library to this computer. The
|
||||
agent fetches the body by digest and runs it in the context the
|
||||
script itself specifies.
|
||||
</p>
|
||||
<!-- Persistent, same reasoning as the Corporate Tools warning: this
|
||||
is arbitrary code on a live machine, and the operator must still
|
||||
be able to see that when they decide to press Run. -->
|
||||
<p class="corptools-warning">
|
||||
A script here runs as SYSTEM unless it is configured to run as the
|
||||
logged-on user. It takes effect immediately on the live computer and
|
||||
cannot be undone from here.
|
||||
</p>
|
||||
|
||||
<div v-if="scriptsloading" class="runscripts-empty">Loading scripts...</div>
|
||||
<div v-else-if="runnablescripts.length === 0" class="runscripts-empty">
|
||||
No runnable scripts. A script needs an uploaded body before it can be
|
||||
sent; add one under Settings then return here.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="form-group">
|
||||
<label for="runscript-picker">Script</label>
|
||||
<select
|
||||
id="runscript-picker"
|
||||
v-model="selectedscriptid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select a script...</option>
|
||||
<option
|
||||
v-for="script in runnablescripts"
|
||||
:key="script.scriptid"
|
||||
:value="script.scriptid"
|
||||
>{{ script.scriptname }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- What the operator is about to run, stated before they run it.
|
||||
Run context and working directory are authoring decisions made
|
||||
in the library and are shown read-only here: overriding either
|
||||
at push time would mean the script that ran is not the script
|
||||
that was reviewed. -->
|
||||
<div v-if="selectedscript" class="runscripts-detail">
|
||||
<p v-if="selectedscript.scriptdescription" class="runscripts-desc">
|
||||
{{ selectedscript.scriptdescription }}
|
||||
</p>
|
||||
<dl class="runscripts-facts">
|
||||
<dt>Runs as</dt>
|
||||
<dd>
|
||||
{{ selectedscript.runcontext === 'user'
|
||||
? 'The logged-on user' : 'SYSTEM' }}
|
||||
</dd>
|
||||
<dt>Runs in</dt>
|
||||
<dd class="mono">
|
||||
{{ selectedscript.workingdirectory || 'Agent staging folder' }}
|
||||
</dd>
|
||||
</dl>
|
||||
<p
|
||||
v-if="selectedscript.workingdirectory"
|
||||
class="runscripts-note"
|
||||
>
|
||||
This script requires that folder to exist on the computer. If it
|
||||
does not, the run fails and reports the missing folder rather
|
||||
than running somewhere else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="runscripts-actions">
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
:disabled="!selectedscript || firingScript"
|
||||
@click="confirmRunScript"
|
||||
>{{ firingScript ? 'Sending...' : 'Run Script' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
|
||||
@@ -686,7 +770,7 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
import { computersApi, applicationsApi, endpointsApi, scriptsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -829,6 +913,16 @@ const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
|
||||
// during setup, which reads canDeploy before this line would otherwise run.
|
||||
const canDeploy = computed(() => authStore.hasPermission('endpoints.corptools'))
|
||||
|
||||
// Gates the Run Scripts tab. Deliberately NOT canDeploy: endpoints.corptools
|
||||
// starts a task the endpoint already holds and has allowlisted, while
|
||||
// endpoints.runscript delivers arbitrary PowerShell that runs as SYSTEM unless
|
||||
// the script pins a user context. Those were split out of the old
|
||||
// endpoints.deploy bundle for exactly this reason (see
|
||||
// ELEVATED_ACTION_PERMISSIONS in plugins/endpoints/commands.py), so hanging
|
||||
// scripts off the Corporate Tools gate would put the bundle back together in
|
||||
// the UI after the backend took it apart.
|
||||
const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript'))
|
||||
|
||||
// Operator report screenshots are an uncontrolled capture of whatever was on
|
||||
// the operator's screen, so they carry their own grant rather than riding
|
||||
// endpoints.view. Declared alongside canDeploy for the same reason: detailtabs
|
||||
@@ -868,6 +962,15 @@ const detailtabs = computed(() => {
|
||||
if (canDeploy.value && liveness.value) {
|
||||
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
|
||||
}
|
||||
// Run Scripts is its own tab rather than a card in Corporate Tools because it
|
||||
// is a different grant and a different trust model - a library of arbitrary
|
||||
// PowerShell delivered by ShopDB, versus a closed set of tasks the endpoint
|
||||
// already approved. Liveness-gated for the same reason as Corporate Tools:
|
||||
// the command queues against a live agent, so offering it for a machine that
|
||||
// is not checking in only produces a command nothing will collect.
|
||||
if (canRunScript.value && liveness.value) {
|
||||
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -1377,6 +1480,47 @@ const corporateTools = [
|
||||
|
||||
const firingTask = ref('')
|
||||
|
||||
// Run Scripts state. The library is fetched rather than hardcoded (the opposite
|
||||
// of corporateTools above) because these are site-authored scripts that change
|
||||
// without an agent release.
|
||||
const scriptlibrary = ref([])
|
||||
const scriptsloading = ref(false)
|
||||
const scriptsloaded = ref(false)
|
||||
const selectedscriptid = ref('')
|
||||
const firingScript = ref(false)
|
||||
|
||||
// Only scripts that can actually run. A row with no uploaded body is rejected
|
||||
// at enqueue by _resolve_run_script, so listing it would offer a button whose
|
||||
// only outcome is an error; the library page is where that gets fixed.
|
||||
const runnablescripts = computed(
|
||||
() => scriptlibrary.value.filter((script) => script.hasbody))
|
||||
|
||||
const selectedscript = computed(
|
||||
() => runnablescripts.value.find(
|
||||
(script) => String(script.scriptid) === String(selectedscriptid.value)) || null)
|
||||
|
||||
// Lazy and permission-gated: an operator without endpoints.runscript never has
|
||||
// the tab, and so never issues a request the server would refuse anyway.
|
||||
async function loadScriptLibrary() {
|
||||
if (scriptsloaded.value || scriptsloading.value) return
|
||||
scriptsloading.value = true
|
||||
try {
|
||||
const response = await scriptsApi.list({ active: 'true' })
|
||||
scriptlibrary.value = response.data.data || []
|
||||
scriptsloaded.value = true
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to load the script library'))
|
||||
} finally {
|
||||
scriptsloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch on first view of the tab rather than on page load: most visits to a PC
|
||||
// never open this, and the library is irrelevant to every other tab.
|
||||
watch(activegroup, (group) => {
|
||||
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
|
||||
})
|
||||
|
||||
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
|
||||
// confirmRestart - these start real work on a live machine and some of them
|
||||
// change system state - and the server re-checks endpoints.corptools regardless.
|
||||
@@ -1412,6 +1556,41 @@ async function confirmRunTask(tool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Elevated: queues a run_script. commands.py asks for this to be gated "the way
|
||||
// system_restart is gated", so the confirmation is TYPED rather than an OK
|
||||
// button - and unlike Corporate Tools there is no non-destructive branch,
|
||||
// because every script here is arbitrary code by definition. The confirm names
|
||||
// the context and the folder so the operator sees what the library decided, not
|
||||
// just the script's name.
|
||||
async function confirmRunScript() {
|
||||
const script = selectedscript.value
|
||||
if (!script) return
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
const context = script.runcontext === 'user'
|
||||
? 'the logged-on user'
|
||||
: 'SYSTEM'
|
||||
const folder = script.workingdirectory || 'the agent staging folder'
|
||||
const typed = prompt(`Run "${script.scriptname}" on ${label}\n\n`
|
||||
+ `It runs as ${context}, in ${folder}.\n`
|
||||
+ 'It starts immediately and cannot be undone from here.'
|
||||
+ '\n\nType RUN to confirm.')
|
||||
if (typed !== 'RUN') return
|
||||
|
||||
firingScript.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
|
||||
// "Queued", not "finished": the agent collects this on its next check-in,
|
||||
// and the script's own result comes back separately. Saying "ran" here
|
||||
// would claim an outcome nothing has observed yet.
|
||||
toast.success(`${script.scriptname} queued for ${label}`)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
|
||||
} finally {
|
||||
firingScript.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
@@ -1864,6 +2043,55 @@ async function confirmRetire() {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Run Scripts reuses the Corporate Tools intro/warning styles above; only the
|
||||
picker-specific pieces are new. */
|
||||
.runscripts-empty {
|
||||
padding: 0.75rem 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.runscripts-detail {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.runscripts-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
/* Two columns so the label and its value stay on one line: the folder is the
|
||||
fact most likely to be skimmed, and wrapping it under its own label made it
|
||||
read as body copy in review. */
|
||||
.runscripts-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.runscripts-facts dt {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.runscripts-facts dd {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
/* Defined locally: .mono is not a global utility in this build, and a path
|
||||
rendered in the body font is harder to read back against the endpoint. */
|
||||
.runscripts-facts dd.mono {
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
word-break: break-all;
|
||||
}
|
||||
.runscripts-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.runscripts-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.triage-chip.critical .triage-dot { background: var(--danger); }
|
||||
.triage-chip.ok { color: var(--success-dark); }
|
||||
.triage-chip.warning { color: var(--warning-dark); }
|
||||
|
||||
85
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal file
85
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Run Scripts tab on PC Details.
|
||||
*
|
||||
* The thing worth pinning here is the SEPARATION. run_script and
|
||||
* scheduledtask_run map to different grants in plugins/endpoints/commands.py
|
||||
* (endpoints.runscript and endpoints.corptools), split out of a single
|
||||
* endpoints.deploy bundle precisely so that being able to trigger a corporate
|
||||
* tool does not also mean being able to run arbitrary PowerShell as SYSTEM.
|
||||
*
|
||||
* That split lives in the backend. The UI can quietly undo it by hanging the
|
||||
* script picker off the Corporate Tools tab, whose only gate is
|
||||
* endpoints.corptools - and the failure would be invisible in review, because
|
||||
* the page would look correct and the server would simply 403 the push. These
|
||||
* tests fail if someone merges the two.
|
||||
*
|
||||
* Asserted against the file text, matching systemScriptsSettings.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, 'PCDetail.vue'), 'utf8')
|
||||
|
||||
describe('PCDetail Run Scripts tab', () => {
|
||||
it('gates the tab on endpoints.runscript, not the Corporate Tools grant', () => {
|
||||
expect(source).toMatch(
|
||||
/const canRunScript = computed\(\(\) => authStore\.hasPermission\('endpoints\.runscript'\)\)/)
|
||||
})
|
||||
|
||||
it('pushes run_script from its own tab, not from the Corporate Tools block', () => {
|
||||
// The corptools block must not gain a run_script call: that would put the
|
||||
// action behind endpoints.corptools in the UI while the server still
|
||||
// demands endpoints.runscript.
|
||||
const corptools = source.slice(
|
||||
source.indexOf("activegroup === 'corptools'"),
|
||||
source.indexOf("activegroup === 'runscripts'"))
|
||||
expect(corptools).not.toMatch(/run_script/)
|
||||
expect(source).toMatch(/activegroup === 'runscripts'/)
|
||||
})
|
||||
|
||||
it('enqueues run_script with a script-id target', () => {
|
||||
expect(source).toMatch(/'run_script', `script:\$\{script\.scriptid\}`/)
|
||||
})
|
||||
|
||||
it('requires a typed confirmation before running', () => {
|
||||
// Every script on this tab is arbitrary code, so unlike Corporate Tools
|
||||
// there is no safe branch that gets a plain OK button.
|
||||
const handler = source.slice(source.indexOf('async function confirmRunScript'))
|
||||
const body = handler.slice(0, handler.indexOf('\n}\n'))
|
||||
expect(body).toMatch(/Type RUN to confirm/)
|
||||
expect(body).toMatch(/typed !== 'RUN'/)
|
||||
})
|
||||
|
||||
it('warns that a script runs as SYSTEM before it is sent', () => {
|
||||
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
|
||||
expect(tab.slice(0, 2000)).toMatch(/runs as SYSTEM/)
|
||||
})
|
||||
|
||||
it('shows the run context and working directory read-only', () => {
|
||||
// Both are authoring decisions made in the library. Offering an override
|
||||
// here would mean the script that ran is not the script that was reviewed,
|
||||
// so they must be rendered as facts and never bound with v-model.
|
||||
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
|
||||
const block = tab.slice(0, tab.indexOf('runscripts-actions'))
|
||||
expect(block).toMatch(/selectedscript\.workingdirectory/)
|
||||
expect(block).toMatch(/selectedscript\.runcontext/)
|
||||
expect(block).not.toMatch(/v-model="[^"]*workingdirectory/)
|
||||
expect(block).not.toMatch(/v-model="[^"]*runcontext/)
|
||||
})
|
||||
|
||||
it('offers only scripts that have a body', () => {
|
||||
// A bodyless script is rejected at enqueue by _resolve_run_script, so
|
||||
// listing it would offer a button whose only outcome is an error.
|
||||
expect(source).toMatch(/filter\(\(script\) => script\.hasbody\)/)
|
||||
})
|
||||
|
||||
it('reports the push as queued rather than as finished', () => {
|
||||
// The agent collects the command on its next check-in; claiming it ran
|
||||
// would assert an outcome nothing has observed.
|
||||
const handler = source.slice(source.indexOf('async function confirmRunScript'))
|
||||
expect(handler.slice(0, handler.indexOf('\n}\n'))).toMatch(/queued for/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user