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:
benjamin.baker
2026-09-09 11:36:42 -04:00
parent f02e344101
commit d6163f54a4
16 changed files with 1411 additions and 20 deletions

View File

@@ -47,4 +47,59 @@ describe('SystemScriptsSettings', () => {
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('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)
})
})