Summary
RunScript() passes the temp script path to powershell.exe positionally, with no -File flag. PowerShell therefore treats it as -Command, and under -Command PowerShell 5.1 reports only 0 (success) or 1 (failure) — so a script's exit 2, exit 3, etc. all reach the server as 1.
https://github.com/amidaware/rmmagent/blob/master/agent/agent_windows.go#L153-L156
case "powershell":
exe = getPowershellExe()
cmdArgs = []string{"-NonInteractive", "-NoProfile", "-ExecutionPolicy", "Bypass", tmpfn.Name()}
The agent's own exit-code extraction (ws.ExitStatus()) is correct — the value is already collapsed by PowerShell before the agent sees it.
Why it matters
Script checks map return codes to severities via info_return_codes / warning_return_codes / success_return_codes. Any mapping that references a code >= 2 is silently dead — the configured severity never applies and the result falls through to whatever the unmapped-non-zero path does.
This fails silently and is close to invisible: the check "works", the output text is correct, and only a diff of the script's exit statements against the stored retcode reveals it. In a ~1,300-agent install we found several checks whose intended severities had never applied, including two cluster-health checks that had been filing genuine faults as informational.
Supporting data from that install: across 260 agents / ~1,240 stored script-check results, the only retcodes ever recorded were 0, 1 and 98 — never 2. (98 is set agent-side on timeout, not by the script.)
Reproduction
t.ps1:
# current agent invocation
powershell.exe -NonInteractive -NoProfile -ExecutionPolicy Bypass t.ps1
echo %ERRORLEVEL% -> 1 # expected 2
# with -File
powershell.exe -NonInteractive -NoProfile -ExecutionPolicy Bypass -File t.ps1
echo %ERRORLEVEL% -> 2
Same collapse for exit 3, exit 98, etc. Windows PowerShell 5.1.
Note: simply adding -File is a breaking change
This looks like a one-word fix, but -File breaks argument binding. Script arguments are stored as free-form strings that contain the flag and value together (e.g. -IgnoreCheck {{client.Foo}} → -IgnoreCheck True, one argv element). -Command re-parses the command line so that binds; -File passes each argv element literally, so the parameter never binds:
# t2.ps1
param([string]$IgnoreCheck)
Write-Output "IgnoreCheck=[$IgnoreCheck]"
exit 2
passing a single argument "-IgnoreCheck True", as the agent does:
| invocation |
result |
exit |
| current (positional path) |
IgnoreCheck=[True] |
1 |
-File |
IgnoreCheck=[] |
2 |
-Command "& 'script' args; exit $LASTEXITCODE" |
IgnoreCheck=[True] |
2 |
Suggested fix
Keep -Command, invoke via & (child scope, so the script's exit sets $LASTEXITCODE instead of terminating the host), and relay it explicitly:
case "powershell":
exe = getPowershellExe()
psCmd := fmt.Sprintf("& '%s'", strings.ReplaceAll(tmpfn.Name(), "'", "''"))
if len(args) > 0 {
psCmd += " " + strings.Join(args, " ")
}
psCmd += "; exit $LASTEXITCODE"
cmdArgs = []string{"-NonInteractive", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psCmd}
...with the later generic args append skipped for the powershell case, since they're already embedded.
Verified locally: this is the only formulation tested that preserves argument binding and propagates the real exit code.
Notes for maintainers
- Behaviour change: installs that worked around this (e.g. mapping
1 where the script exits 2) would see severities change once codes propagate. Probably worth a release note.
- Only the
powershell branch is affected — python and cmd execute the file directly and already propagate exit codes.
- PowerShell 7 isn't a factor:
getPowershellExe() (agent/utils.go) resolves powershell.exe only, with a System32\WindowsPowerShell\v1.0 fallback, and there are no pwsh references in the agent.
- Separate pre-existing issue, not addressed above: an argument value containing spaces doesn't bind under either formulation (
-clientname Foo Bar Inc. binds as Foo). That's a function of how args are stored/joined rather than this invocation — mentioning it only so it isn't conflated with this bug.
Happy to open a PR if the suggested approach looks right.
Summary
RunScript()passes the temp script path topowershell.exepositionally, with no-Fileflag. PowerShell therefore treats it as-Command, and under-CommandPowerShell 5.1 reports only 0 (success) or 1 (failure) — so a script'sexit 2,exit 3, etc. all reach the server as 1.https://github.com/amidaware/rmmagent/blob/master/agent/agent_windows.go#L153-L156
The agent's own exit-code extraction (
ws.ExitStatus()) is correct — the value is already collapsed by PowerShell before the agent sees it.Why it matters
Script checks map return codes to severities via
info_return_codes/warning_return_codes/success_return_codes. Any mapping that references a code >= 2 is silently dead — the configured severity never applies and the result falls through to whatever the unmapped-non-zero path does.This fails silently and is close to invisible: the check "works", the output text is correct, and only a diff of the script's
exitstatements against the storedretcodereveals it. In a ~1,300-agent install we found several checks whose intended severities had never applied, including two cluster-health checks that had been filing genuine faults as informational.Supporting data from that install: across 260 agents / ~1,240 stored script-check results, the only retcodes ever recorded were
0,1and98— never2. (98is set agent-side on timeout, not by the script.)Reproduction
t.ps1:Same collapse for
exit 3,exit 98, etc. Windows PowerShell 5.1.Note: simply adding
-Fileis a breaking changeThis looks like a one-word fix, but
-Filebreaks argument binding. Script arguments are stored as free-form strings that contain the flag and value together (e.g.-IgnoreCheck {{client.Foo}}→-IgnoreCheck True, one argv element).-Commandre-parses the command line so that binds;-Filepasses each argv element literally, so the parameter never binds:passing a single argument
"-IgnoreCheck True", as the agent does:IgnoreCheck=[True]-FileIgnoreCheck=[]-Command "& 'script' args; exit $LASTEXITCODE"IgnoreCheck=[True]Suggested fix
Keep
-Command, invoke via&(child scope, so the script'sexitsets$LASTEXITCODEinstead of terminating the host), and relay it explicitly:...with the later generic
argsappend skipped for the powershell case, since they're already embedded.Verified locally: this is the only formulation tested that preserves argument binding and propagates the real exit code.
Notes for maintainers
1where the script exits2) would see severities change once codes propagate. Probably worth a release note.powershellbranch is affected —pythonandcmdexecute the file directly and already propagate exit codes.getPowershellExe()(agent/utils.go) resolvespowershell.exeonly, with aSystem32\WindowsPowerShell\v1.0fallback, and there are nopwshreferences in the agent.-clientname Foo Bar Inc.binds asFoo). That's a function of how args are stored/joined rather than this invocation — mentioning it only so it isn't conflated with this bug.Happy to open a PR if the suggested approach looks right.