Invoke-VMScript: run a script inside a VM without guest networking

Invoke-VMScript is the PowerCLI cmdlet that runs a script inside the guest operating system of a virtual machine. It does not use the guest’s network. It talks to the ESXi host, and the host hands the script to VMware Tools inside the VM, which is why it keeps working on a machine whose IP configuration you have just broken from the other side of the country.

That makes it the tool of last resort for a VM you can no longer reach, and the tool of first resort for anything you want to do to fifty VMs without arranging WinRM or SSH on all of them. It is also one of the easiest cmdlets in PowerCLI to use incorrectly, because the text you hand it is interpreted by a shell you did not choose, on a machine you are not looking at.

Broadcom’s reference page documents twelve parameters and three examples. Its Output section is empty, and its three examples use two different scripting languages without ever saying which one you get by default. This page fills those two gaps, and everything it claims about how PowerShell reads your script text was measured rather than remembered.


Quick answer

Connect, pick the VM, supply guest credentials as a PSCredential, and send one harmless command. If hostname comes back, everything else in this article will work.

# Get-Credential keeps the password out of your console history and out of
# transcripts, and it produces the PSCredential type the cmdlet actually binds
$cred = Get-Credential

Connect-VIServer vcenter.corp.local
$vm = Get-VM -Name SRV-PROD-01

# ScriptText is one string. On a Windows guest it is read as PowerShell
Invoke-VMScript -VM $vm -ScriptText 'hostname' -GuestCredential $cred
Warning: The VM must be powered on with VMware Tools running, and your vSphere account needs guest operation privileges. Those are two different failures with two different fixes, and the privileges section below separates them.

What it does

The cmdlet sends your script text to the ESXi host that owns the VM. VMware Tools, running inside the guest, receives it and executes it as the guest user you authenticated with. Nothing travels over the guest’s own network adapter, so a VM with a wrong subnet mask, a disabled NIC or a firewall rule that locked you out is still reachable.

Broadcom’s page is explicit that the connectivity you need is yours to the ESXi host, not yours to the guest. That single sentence is the whole reason the cmdlet exists.

The reference lists twelve parameters. These are the ones that decide whether a call works at all, and the ones the rest of this page comes back to.

ParameterTypeWhat it is for
-VMVirtualMachine[]Required, position 1, accepts pipeline input by value. An array, so one call can target many VMs.
-ScriptTextStringRequired. The script itself, as a single string. Documented as also accepting a string variable holding a path to a script.
-ScriptTypeScriptTypePowerShell, Bat or Bash. Defaults to PowerShell on a Windows guest and Bash on a Linux guest.
-GuestCredentialPSCredentialCredentials for the guest OS. The form used by both of Broadcom’s worked examples.
-GuestUser / -GuestPasswordString / SecureStringThe split form. Note the password type: it is a SecureString, not a String.
-HostUser / -HostPassword / -HostCredentialString / SecureString / PSCredentialDocumented as needed only against vCenter or ESX earlier than 4.0, or a VIX earlier than 1.10.
-ToolsWaitSecsInt32How long to wait when connecting to VMware Tools. Default 20.
-RunAsyncSwitchParameterReturns immediately with a Task object instead of the script result.
Note: -ToolsWaitSecs is the wait for connecting to VMware Tools, not a timeout for your script. A guest that is still booting is the usual reason the default 20 seconds is not enough.

Before the first example

Four things have to be true before Invoke-VMScript can do anything at all. Checking them in this order turns most failures into a one line answer instead of a support case.

1. PowerCLI is installed and you are connected. Everything below assumes an open session against vCenter. If the module is not installed yet, start with the install guide linked at the end of this page.

# Connect-VIServer against vCenter, or straight at a standalone ESXi host
Connect-VIServer vcenter.corp.local

2. The VM is powered on and VMware Tools is running. The reference requires both. PoweredOn with Tools stopped fails exactly the same way as PoweredOff, so read the two facts together rather than assuming.

# PowerState and the Tools status in one line, because either one alone is
# not an answer. Emitting a formatted string keeps the output identical in a
# console, in a transcript and in a redirected file
Get-VM SRV-PROD-01 |
    ForEach-Object { "{0}  {1}  Tools: {2}" -f $_.Name, $_.PowerState, $_.ExtensionData.Guest.ToolsRunningStatus }

The vSphere API documents three values for that field: guestToolsRunning, guestToolsNotRunning and guestToolsExecutingScripts. Only the first one means the channel is ready.

3. You have a guest account, held as a PSCredential. Not a user name and a password typed onto the command line. The reason is in the parameter table above and is measured in the credentials section below.

# Prompts once, stores the password as a SecureString, and can be reused for
# every call in the session
$cred = Get-Credential -UserName 'SRV-PROD-01\Administrator' -Message 'Guest OS account'

4. Your vSphere account holds guest operation privileges. Read access to the folder containing the VM is not enough, and neither is a role that can power the VM on. The exact privilege names differ between two Broadcom pages, which the privileges section sorts out.

Note: Every output block on this page that shows PowerShell behaviour is a capture from a real PowerShell 7.4.6 session, not an illustration. The Invoke-VMScript calls themselves are shown without invented output, because neither Broadcom reference publishes any.

Practical examples

1. Prove the channel works before you trust it

The problem: A script you sent came back empty and you cannot tell whether the guest refused it, the credentials were wrong, or the script itself did nothing.

The solution: Send the smallest command that cannot fail for any reason except the channel itself, and read what comes back before sending anything real.

# hostname exists on every Windows and Linux guest, writes one line, changes
# nothing, and needs no arguments, so anything other than that one line is a
# fact about the channel rather than about the script
Invoke-VMScript -VM SRV-PROD-01 -ScriptText 'hostname' -GuestCredential $cred
Result: The guest name comes back. From here every later failure is your script’s fault, which is a much smaller problem than not knowing whose fault it is.

2. Read a VM you can no longer reach

The problem: Someone changed the IP configuration on SRV-PROD-01 and the machine dropped off the network. RDP is gone, WinRM is gone, and the console is a slow way to read twenty lines of output.

The solution: The cmdlet needs your connectivity to the ESXi host, not to the guest, so the guest being unreachable is exactly the case it was built for.

# ipconfig /all is read only and gives the adapter, the address, the mask,
# the gateway and the DNS servers in one pass, which is usually the whole
# diagnosis for a machine that vanished after a network change
Invoke-VMScript -VM SRV-PROD-01 -ScriptText 'ipconfig /all' -GuestCredential $cred
Note: ipconfig is an executable, so it runs the same way under either script type. That is not true of everything, and the next example is the reason why.

3. Use cmd semantics on purpose, not by accident

The problem: You pasted a line that works perfectly in a cmd window on the guest, and through Invoke-VMScript it fails with something about a command that is not recognised.

The solution: On a Windows guest the default script type is PowerShell, so cmd syntax is being read as PowerShell. Ask for Bat explicitly, and write the environment variables the way that shell expects.

# %temp% is cmd syntax. PowerShell would not expand it either, so the quoting
# is not what saves this line: -ScriptType Bat is. Single quotes are still the
# right habit, because they also protect a $ if the string ever gains one
$script = 'dir "%temp%" /b'

# Without -ScriptType Bat this text goes to PowerShell, where %temp% is not a
# variable reference at all and the whole thing becomes a command name
Invoke-VMScript -VM SRV-PROD-01 -ScriptText $script -GuestCredential $cred -ScriptType Bat
Common mistake: Sending cmd text without -ScriptType Bat does not produce a syntax error you can see from your own console. It parses cleanly as PowerShell and fails inside the guest, which is measured in the next section.

4. Run across several VMs, with the filter in the right place

The problem: You need the installed build number from every web front end, and logging into each one is an afternoon.

The solution: -VM takes pipeline input by value and accepts an array, so Get-VM feeds it directly. Filter and count first, because the pipeline is what decides how many machines you just ran a script on.

# Step one: look at the list. Never pipe a Get-VM straight into a cmdlet that
# executes something, without having read the list it produces first
$targets = Get-VM -Name 'SRV-WEB-*' | Where-Object PowerState -eq 'PoweredOn'
$targets.Count
$targets.Name

# Step two: run it, only once the count above is the number you expected
$targets | Invoke-VMScript -ScriptText '(Get-CimInstance Win32_OperatingSystem).BuildNumber' -GuestCredential $cred
Warning: A wildcard that matches more than you meant is a fleet wide mistake here rather than a single machine mistake. Get-VM 'SRV-WEB-*' and Get-VM 'SRV-*' differ by one character and by a great many virtual machines.

5. Find out what actually comes back

The problem: You want to keep the result, compare it across machines or write it to a file, and the reference’s Output section is empty, so there is nothing to read about the object you are being handed.

The solution: Ask the object. Get-Member answers the question the documentation does not, and it answers it for the exact PowerCLI version you are running rather than for the one somebody blogged about.

# Capture rather than display, then ask what the type is and what it carries.
# This is the habit worth keeping: it costs one call and it survives version
# changes that a memorised property name does not
$result = Invoke-VMScript -VM SRV-PROD-01 -ScriptText 'hostname' -GuestCredential $cred

$result.GetType().FullName
$result | Get-Member -MemberType Properties
Note: This is the closing step for a read only tool. There is nothing to undo after an Invoke-VMScript that only reads, so the step that makes the reading durable is knowing what you are holding and being able to store it.

One string, three languages

This is the part of Invoke-VMScript that costs people the most time, and the reference demonstrates it without ever naming it. Broadcom’s Example 2 and Example 3 perform the same task, launching msinfo32.exe with a report path, and the two script strings have almost nothing in common.

Guest OSDefault -ScriptTypeHow an environment variable is writtenHow a program is launched
WindowsPowerShell$env:ProgramFileswith a leading &
Windows, asked for explicitlyBat%programfiles%by naming the path
LinuxBash$HOMEby naming the path

The dangerous half of this is that sending the wrong form does not look like a mistake from your side. Every claim below was measured in PowerShell 7.4.6 using the language parser itself, so these are not opinions about what PowerShell probably does.

A cmd style variable reference parses cleanly and then fails in the guest

%programfiles%\tool.exe /report is perfectly good cmd. Handed to PowerShell it is still accepted, because PowerShell reads it as the name of a command rather than as a variable reference. There is no syntax error to catch before it leaves your machine.

# Ask the parser directly rather than guessing what PowerShell will make of it
$P = [System.Management.Automation.Language.Parser]
$e = $null
$ast = $P::ParseInput('%programfiles%\tool.exe /report', [ref]$null, [ref]$e)
$cmd = $ast.Find({param($n) $n -is [System.Management.Automation.Language.CommandAst]}, $true)

'parse errors : {0}' -f $e.Count
'command name : {0}' -f $cmd.GetCommandName()
parse errors : 0
command name : %programfiles%\tool.exe
Common mistake: Zero parse errors. The text is valid PowerShell that names a command which does not exist, so the failure surfaces inside the guest as a command not found, several seconds later, with nothing in it that points at the script type.

Leaving out the ampersand returns the path instead of running it

Broadcom’s Example 2 says the ampersand is required in front of the program path. Here is what it costs to leave it out: the quoted path is simply a string expression, so PowerShell evaluates it to itself and reports success.

# Read $? and $LASTEXITCODE immediately after each statement, so they describe
# that statement and not the one that printed the last line
$a = "/usr/bin/uname"
$ok1 = $?; $ec1 = if ($null -eq $LASTEXITCODE) { 'unset' } else { $LASTEXITCODE }

$b = & "/usr/bin/uname"
$ok2 = $?; $ec2 = $LASTEXITCODE

'no ampersand   -> type {0}, value {1}' -f $a.GetType().Name, $a
'                  $? {0}, LASTEXITCODE {1}' -f $ok1, $ec1
'with ampersand -> type {0}, value {1}' -f $b.GetType().Name, $b
'                  $? {0}, LASTEXITCODE {1}' -f $ok2, $ec2
no ampersand   -> type String, value /usr/bin/uname
                  $? True, LASTEXITCODE unset
with ampersand -> type String, value Linux
                  $? True, LASTEXITCODE 0

The measurement ran on Linux, which is why the path looks like that. The behaviour being measured belongs to the PowerShell parser rather than to the platform, and it is identical for "C:\Windows\System32\ipconfig.exe" on a Windows guest: without the ampersand you get the path back, $? still says True and no exit code is set, which is very easy to mistake for a result.

Chaining with an ampersand means three different things

Joining two commands with & is ordinary cmd. In PowerShell 7 the same text is accepted and means something else entirely: about_Operators states that the background operator “is also a statement terminator”, so the command in front of it is sent to a job.

# A line any admin might paste, read by the PowerShell parser
$e2 = $null
$ast2 = $P::ParseInput('hostname & ipconfig /all', [ref]$null, [ref]$e2)

'parse errors : {0}' -f $e2.Count
foreach ($p in $ast2.FindAll({param($n) $n -is [System.Management.Automation.Language.PipelineAst]}, $true)) {
    'pipeline "{0}" background={1}' -f $p.Extent.Text, $p.Background
}
parse errors : 0
pipeline "hostname" background=True
pipeline "ipconfig /all" background=False
Where the text landsWhat hostname & ipconfig /all does
cmd, or -ScriptType BatRuns both, in order, and you see both results
PowerShell 7 in the guestRuns hostname as a background job and only ipconfig /all returns
Windows PowerShell 5.1 in the guestBackgrounding with & arrived in PowerShell 6.0, so the operator is not there

One string, sent to two Windows guests that differ only in whether somebody installed PowerShell 7, produces two different outcomes. Use a semicolon, which separates statements in every version of PowerShell, or ask for -ScriptType Bat and keep the cmd meaning you intended.

Quote marks decide which machine expands your variables

Broadcom writes its example scripts in single quotes and explains that this is how you define a string in PowerShell. The more useful way to read it is that single quotes are what stop your session from expanding the variable, so the guest gets to.

# The local session has its own ProgramFiles. Watch which form leaks it
$env:ProgramFiles = '/opt/local-test'

'single : ' + '&"$env:ProgramFiles\tool.exe" /report'
'double : ' + "&`"$env:ProgramFiles\tool.exe`" /report"
single : &"$env:ProgramFiles\tool.exe" /report
double : &"/opt/local-test\tool.exe" /report
Warning: The double quoted form sends your own machine’s path into the guest. On a workstation where the variable happens to hold the same value it works, which is how this survives testing and fails in production against a guest with a different install drive.

Credentials and privileges

The syntax block types -GuestPassword as a SecureString. Broadcom’s own Example 1 passes it a bare word. Those two cannot both be right, and PowerShell settles it: the engine will not convert a String to a SecureString for a parameter that declares one.

# A parameter declared exactly as the reference declares it
function Test-Bind { param([System.Security.SecureString]$GuestPassword) $GuestPassword.Length }

try { Test-Bind -GuestPassword 'pass2' } catch { $_.Exception.Message }
Cannot process argument transformation on parameter 'GuestPassword'. Cannot convert the value of type "System.String" to type "System.Security.SecureString".

Use -GuestCredential with a PSCredential, which is the form both of Broadcom’s other examples use. It also keeps the password out of your console history, out of any PowerShell transcript, and out of the screenshot you were about to paste into a ticket.

On the vSphere side, the current page and the archived VMware page name different privileges for the same cmdlet, because the requirement changed across versions and the current page still carries the oldest one.

Target versionPrivilege namedWhich page says so
Earlier than 4.1VirtualMachine.Interact.ConsoleInteractArchived VMware reference
4.1 and laterVirtualMachine.Interact.GuestControlArchived VMware reference
vCenter Server or ESXi 5.0 and laterVirtualMachine.GuestOperations.Modify and VirtualMachine.GuestOperations.ExecuteBoth pages
Note: Both pages also require read access to the folder containing the virtual machine, and the current page still opens by asking for a Console Interaction privilege. On anything modern the pair in the last row is the one that decides whether the call is allowed.

Two more requirements appear only on the archived page and were dropped from the current one: connectivity to the ESX host on TCP port 902, and a guest account that has administrator privileges. Neither is contradicted anywhere, so treat both as still true when a call fails and the privileges above are already granted.


Hidden gems

The default script type on a Windows guest reads dir as a cmdlet. Broadcom’s Example 1 is -ScriptText "dir", and on a Windows guest that text is handed to PowerShell, where dir is an alias. Microsoft documents the alias on all platforms, and it resolves like this.

(Get-Command dir).Definition
Get-ChildItem

So even the reference’s simplest example does not run cmd’s dir. It runs Get-ChildItem, with a different output shape, different column names and different behaviour on a path with a wildcard in it.

-RunAsync hands you a Task, not your output. The reference is clear that in this mode “the output of the cmdlet is a Task object”. A call that returns instantly with something that is obviously not your script’s result is usually this switch rather than a guest problem.

-ToolsWaitSecs is not a script timeout. It is documented as how long the system waits for connecting to VMware Tools, and it defaults to 20. A guest that is mid boot, or one where the Tools service was just restarted, is the usual reason to raise it. A long running script is not.

The documentation is ambiguous about script files, so avoid them. -ScriptText is described as also accepting “a string variable containing the path to the script”, and neither page says whose filesystem that path belongs to, yours or the guest’s. Read the file yourself and pass its contents, which removes the question entirely.

# -Raw keeps the file as one string with its line breaks intact, which is what
# ScriptText wants. This also lets you see exactly what you are about to send
$body = Get-Content C:\bat\collect-build.ps1 -Raw
$body

Invoke-VMScript -VM SRV-PROD-01 -ScriptText $body -GuestCredential $cred
Note: Printing $body before sending it is worth the one line. It is the only point at which you can see the script the way the guest will see it, quoting and all.

A Windows guest and a Linux guest

The same cmdlet covers both, and the only thing that changes is which shell your text lands in. On a Linux guest the default is Bash, so ordinary shell syntax works without asking for anything.

# Bash is already the default on a Linux guest, so -ScriptType is optional
# here. Naming it anyway documents the intent for whoever reads the script next
Invoke-VMScript -VM SRV-APP-02 -ScriptText 'df -hT /var' -GuestCredential $cred -ScriptType Bash

Sending the same intent to a Windows guest means a different string, not a different switch. These two lines ask the same question of the two operating systems.

QuestionWindows guest, default PowerShellLinux guest, default Bash
Which machine am I onhostnamehostname
How much space is leftGet-PSDrive Cdf -hT /
What is the OS build(Get-CimInstance Win32_OperatingSystem).BuildNumberuname -r
Is a service runningGet-Service Spoolersystemctl is-active sshd
Note: If you only want free space per volume across a fleet, Get-VMGuestDisk answers it without a guest login at all, and is far lighter than a script call.

Where this matters

  • A network change locked you out. Wrong mask, wrong VLAN, a firewall rule applied too broadly: the guest is gone from the network and still fully reachable through the host.
  • Remoting was never set up. WinRM and SSH both need configuration on every target. VMware Tools is already there on every VM you care about.
  • A template needs finishing. The machine has just been cloned, has no domain membership and no working name resolution, and still needs one command run inside it.
  • An audit question spans the fleet. One build number, one registry value, one service state, from every VM in a folder, without touching the network path at all.
  • The console is too slow to read. Anything longer than a few lines is painful through a web console, and comes back as text here.

Tips and limitations

  • The VM must be powered on with VMware Tools running. No Tools means no channel, whatever the power state says.
  • Name -ScriptType explicitly whenever the script is not plain PowerShell. Relying on the default is what turns a cmd one liner into a command not found inside the guest.
  • Use single quotes around -ScriptText. Double quotes expand your own session’s variables before the text ever leaves your machine.
  • Separate statements with a semicolon rather than an ampersand, unless you have asked for -ScriptType Bat and mean cmd chaining.
  • Pass credentials as a PSCredential. The split -GuestUser and -GuestPassword form needs a SecureString, and the archived reference adds that the guest account needs administrator privileges.
  • Read the Get-VM output before piping it into this cmdlet. The pipeline decides how many machines run your script.
  • It is heavier than a purpose built cmdlet. Where PowerCLI already has one, such as Get-VMGuestDisk for volumes, prefer it.

Official documentation


Related tools

  • PowerCLI Command Builder: builds the Connect-VIServer and Get-VM lines this article starts from, which are the two prerequisites every example here depends on.

Related guides