1

When deploying PowerShell scripts from my RMM (NinjaOne), the scripts are called from a .bat (batch file).

Example:

@powershell -ExecutionPolicy Bypass -File "test.ps1" -stringParam "testing" -switchParam > "output.txt" 2>&1

The script I am calling requires PowerShell 7+, so I need to restart the script by calling pwsh with the current parameters. I planned to accomplish this via the following:

Invoke-Command { & pwsh -Command $MyInvocation.Line } -NoNewScope

Unfortunately, $MyInvocation.Line does not return the correct result when a PowerShell script is called from a batch file. What alternatives exist that would work in this scenario?

Notes:

  • I am unable to make changes to the .bat file.
  • $PSBoundParameters also does not return the expected result.

Testing Script (called from batch):

Param(
  [string]$string,
  [switch]$switch
)

if (!($PSVersionTable.PSVersion.Major -ge 7)) {
  Write-Output "`n"
  Write-Output 'Attempting to restart in PowerShell 7'
  if(!$MyInvocation.Line) {
    Write-Output 'Parameters not carried over - cannot restart' 
    Exit 
  } else { Write-Output $MyInvocation.Line }
  Invoke-Command { & pwsh -Command $MyInvocation.Line } -NoNewScope # PowerShell 7
  Exit
}

Write-Output 'Parameters carried over:'
Write-Output $PSBoundParameters

Write-Output "`nSuccessfully restarted"

Edit: I've discovered the reason for $MyInvocation / $PSBoundParameters not being set properly is due to the use of -File instead of -Command when my RMM provider calls the PowerShell script from the .bat file. I've suggested they implement this change to resolve the issue. Until they do, I am still looking for alternatives.

wise-io
  • 67
  • 6
  • 1
    As an aside: It is virtually pointless to use [`Invoke-Command`](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/invoke-command) for _local_ invocations - see [this answer](https://stackoverflow.com/a/60980641/45375). – mklement0 Feb 12 '22 at 03:43

3 Answers3

2

Leaving RMM providers out of the picture (whose involvement may or may not matter), the following test.ps1 content should work:

# Note: Place [CmdletBinding()] above param(...) to make
#       the script an *advanced* one, which then prevents passing
#       extra arguments that don't bind to declared parameters.
param(
  [string] $stringParam,
  [switch] $switchParam
)

# If invoked via powershell.exe, re-invoke via pwsh.exe
if ((Get-Process -Id $PID).Name -eq 'powershell') {
   # $PSCommandPath is the current script's full file path,
   # and @PSBoundParameters uses splatting to pass all 
   # arguments that were bound to declared parameters through.
   # Any extra arguments, if present, are passed through with @args
   pwsh -ExecutionPolicy Bypass -File $PSCommandPath @PSBoundParameters @args
   exit $LASTEXITCODE
}

# Getting here means that the file is being executed by pwsh.exe

# Print the arguments received:

if ($PSBoundParameters.Count) {
  "-- Bound parameters and their values:`n"
  # !! Because $PSBoundParameters causes table-formatted
  # !! output, synchronous output must be forced to work around a bug.
  # !! See notes below.  
  $PSBoundParameters | Out-Host
}

if ($args) {
  "`n-- Unbound (positional) arguments:`n"
  $args
}

exit 0

As suggested in the code comments, place [CmdletBinding()] above the param(...) block in order to make the script an advanced one, in which case passing extra arguments (ones that don't bind to formally declared parameters) is actively prevented (and in which case $args isn't defined).

Caveat:

  • Note the need for piping $PSBoundParameters to Out-Host in order to force synchronous output of the (default) table-formatted representation of its value. The same would apply to outputting any other values that would result in implicit Format-Table formatting not based on predefined formatting data ($args, as an array whose elements are strings isn't affected). (Note that While Out-Host output normally isn't suitable for data output from inside a PowerShell session, it does write to an outside caller's stdout.)

  • This need stems from a very unfortunate output-timing bug, present in both Windows PowerShell and the current PowerShell (Core) version, 7.2.1; it is a variant manifestation of the behavior in detail in this answer and reported in GitHub issue #13985 and applies here because exit is called within 300 msecs. of initiating the implicitly table-formatted output; if you were to omit the final exit 0 statement, the problem wouldn't arise.

See also:

  • The automatic $PSCommandPath variable, reflecting the running script's full (absolute) path.

  • The automatic $PSBoundParameters variable, a dictionary containing all bound parameters and their values (arguments), and the automatic $args variable, containing all (remaining) positional arguments not bound to formally declared parameters.

  • Parameter splatting, in which referencing a hashtable (dictionary) or array variable with sigil @ instead of $, passes the hashtable's entries / array's elements as individual arguments.

  • powershell.exe, the Windows PowerShell CLI; pwsh, the PowerShell (Core) 7+ CLI.

  • Other automatic variables used above, namely $PID (the current process' ID) and $LASTEXITCODE (the most recently executed external program's exit code).


As for what you tried:

  • $MyInvocation.Line isn't defined when a script is called via PowerShell's CLI; however, the automatic $PSCommandPath variable, reflecting the running script's full file path, is defined.

    • Even if $MyInvocation.Line were defined, it wouldn't enable robust pass-through of the original arguments, due to potential quoting issues and - when called from inside PowerShell - due to reflecting unexpanded arguments. (Also, the value would start with the executable / script name / path, which would have to be removed.)

    • While [Environment]::CommandLine does reflect the process command line of the CLI call (also starting with the executable name / path), the quoting issues apply there too.

  • Also, it is virtually pointless to use Invoke-Command for local invocations - see this answer.

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • The script you suggested does not work when called from a batch file. $PSBoundParameters is empty. – wise-io Feb 12 '22 at 14:11
  • @wise-io: The use of a batch file is incidental to whether `$PSBoundParameters` is filled or not. It is only empty in two cases: (a) your script has no formally declared parameters (no `param(...)` block) or (b) your script isn't an advanced one and you've only passed arguments that didn't bind to the formally declared ones (in which case they'll be reflected in `$args`. This answer shows the use of a `param(...)` block with parameter declarations inferred from the sample call in your question - please see my update, which now also passes _unbound_ arguments through. – mklement0 Feb 12 '22 at 14:39
  • I ran the script you suggested via the following batch file: `@powershell -ExecutionPolicy Bypass -File ".\test.ps1" -string "testing" -switch > ".\output.txt" 2>&1` `output.txt` contained the following after running: `Bound parameters and their values:` As you can see, `$PSBoundParameters` is not working properly. – wise-io Feb 12 '22 at 15:33
  • @wise-io: `$PSBoundParameters` itself is working properly, but you're seeing the effects of an unrelated, very unfortunate output-timing bug that I did not anticipate - please see my update; in short: use `$PSBoundParameters | Out-Host` instead of just `$PSBoundParameters` – mklement0 Feb 12 '22 at 18:41
0

I'd try putting a file called powershell.bat early on your path (at least, in a directory earlier on the path than the C:\Windows\System32\WindowsPowerShell\v1.0\; entry) and assemble the appropriate parameters (I've no idea of your required structure for $myinvocationline - no doubt it could be derived from the parameters delivered to powershell.bat).

My thinking is that this should override powershell, re-assemble the bits and deliver them to pwsh.

Magoo
  • 77,302
  • 8
  • 62
  • 84
  • `$MyInvocation` is an automatic variable in PowerShell, not a variable that I've created. I am simply trying to get the same script/parameters used and restart in pwsh with those values. See here: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-7.2#myinvocation – wise-io Feb 12 '22 at 02:40
  • To clarify further, this solution won't work. They actually call PowerShell via `@%WINDIR%\sysnative\windowspowershell\v1.0\powershell.exe`. I shortened it for the sake of brevity. I didn't foresee a suggestion like this. My apologies. – wise-io Feb 12 '22 at 02:51
  • OK - so on the same theme, could you create an executable `myex.exe` which assembles the parameters currently being delivered to `powershell.exe`, formats them appropriately for `pwsh` and executes `pwsh`. Then simply rename `powershell.exe` (eg to `powershell.xex`), copy `myexe.exe` to `powershell.exe` in its place, run your process, then reverse the rename to restore normality? – Magoo Feb 12 '22 at 03:14
0

I put this line in test.ps1:

$MyInvocation | Format-List -Property *

Found this content in output.txt:



MyCommand             : test.ps1
BoundParameters       : {}
UnboundArguments      : {-stringParam, testing, -switchParam}
ScriptLineNumber      : 0
OffsetInLine          : 0
HistoryId             : 1
ScriptName            : 
Line                  : 
PositionMessage       : 
PSScriptRoot          : 
PSCommandPath         : 
InvocationName        : D:\Temp\StackOverflow\71087897\test.ps1
PipelineLength        : 2
PipelinePosition      : 1
ExpectingInput        : False
CommandOrigin         : Runspace
DisplayScriptPosition : 



Then tried this in test.ps1:

[string]$MySelf = $MyInvocation.InvocationName
Write-Host "###$MySelf###"
[string[]]$Params = $MyInvocation.UnboundArguments
foreach ($Param in $Params) {
    Write-Host "Param: '$Param'"
}

And found this in output.txt:

###D:\Temp\StackOverflow\71087897\test.ps1###
Param: '-stringParam'
Param: 'testing'
Param: '-switchParam'

Then tried this in test.ps1:

[string]$Line = "$($MyInvocation.InvocationName) $($MyInvocation.UnboundArguments)"
Write-Host "$Line"

And found this in output.txt:

D:\Temp\StackOverflow\71087897\test.ps1 -stringParam testing -switchParam

Does this get you to where you need to be?

Darin
  • 1,423
  • 1
  • 10
  • 12
  • If there are spaces in the path, you may want to use this: [string]$Line = "'$($MyInvocation.InvocationName)' $($MyInvocation.UnboundArguments)" – Darin Feb 12 '22 at 03:05
  • Now, try calling this script from a batch file with the -File parameter. Unfortunately, it won't work. – wise-io Feb 12 '22 at 14:13
  • What does this command show?: $MyInvocation | Format-List -Property * – Darin Feb 12 '22 at 14:32
  • It does show the parameters used in BoundParameters, but I am unable to get any output from $MyInvocation.BoundParameters. – wise-io Feb 12 '22 at 15:49
  • So, when you run a ps1 file, with parameters passed to it, and in the ps1 file you try something like Write-Host $MyInvocation.BoundParameters, there is no output? – Darin Feb 12 '22 at 16:07
  • that is correct. – wise-io Feb 12 '22 at 17:19
  • I don't understand how is possible the $MyInvocation | Format-List -Property * command shows the info you need, but you aren't getting the properties when you try to access them directly. My only other option would be to modify the Batch to save the command in the environmental variables and retrieve them in PS with $Env, but you say you can't modify the batch file, so that puts you in a hard situation. – Darin Feb 12 '22 at 17:57