Files
shopdb-flask/plugins/computers/frontend/views/pcDetailRunScripts.spec.js
benjamin.baker d6163f54a4 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.
2026-09-09 11:36:42 -04:00

86 lines
3.9 KiB
JavaScript

/**
* 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/)
})
})