Skip to content

PowerShell script exit codes collapse to 1 (script path passed without -File) #83

Description

@Zebble

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:

exit 2
# 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions