Copy-VMGuestFile: copying files into a VM with no network

Copy-VMGuestFile is the PowerCLI cmdlet that moves a file between your workstation and the inside of a virtual machine. It does not use the guest network. The file travels through vCenter Server to the ESXi host, and VMware Tools inside the guest writes it to disk, which is why it still works on a machine whose IP configuration you broke ten minutes ago.

That makes it the way to get a log off an unreachable VM, the way to put an installer onto an appliance with no SSH, and the way to seed a file onto fifty guests without arranging WinRM on any of them. The companion cmdlet Invoke-VMScript runs commands through the same channel, and that cmdlet has its own page here.

Broadcom publishes two examples for this cmdlet. Both of them fail at the PowerShell parameter binder before any connection is attempted. This page shows what the binder actually says, measured rather than remembered, and it spends most of its length on the thing the reference states in two sentences and nobody reads twice: the direction switch decides which of your two paths is allowed to be relative.

Applies to: VMware PowerCLI 13.3 against vCenter Server or ESXi 5.0 and later


Quick answer

Connect, build a credential object, and name the direction. The destination below sits inside the VM, so it has to be an absolute path. If you want to understand why the same command with the paths swapped returns an error instead of a file, read on from the top.

# Get-Credential keeps the guest password out of your console history and
# produces the PSCredential type the cmdlet binds without complaint
$cred = Get-Credential -UserName 'SRV-PROD-01\Administrator' -Message 'Guest OS account'

Connect-VIServer vcenter.corp.local

# -LocalToGuest: the source is on this machine, the destination is inside the
# VM. -Force creates C:\Temp in the guest if it is not there already
Copy-VMGuestFile -Source 'C:\vmguestlab\payload.txt' -Destination 'C:\Temp\' -VM 'SRV-PROD-01' -LocalToGuest -Force -GuestCredential $cred
Warning: A successful call prints nothing at all. The Output section of the reference page is empty and the call returns no object, so "no error" is the only thing the cmdlet itself tells you. The verification section below turns that into an answer.

What you are about to build, and why

The lab on this page is nine PowerShell files and two small text files in one folder, C:\vmguestlab. Nothing is installed, no module is imported, no vCenter object is touched and no guest is written to. Seven of the nine files run on any machine with PowerShell 7, with or without PowerCLI, because they measure the rules and the parameter binding rather than the copy.

At the end you have a preflight that tells you which VMs can be copied into, a rule checker that refuses a path pair the cmdlet would reject, a wrapper that composes the real command line without running it, and a verifier that proves the bytes arrived.

FileWhat it does
GuestPathRules.ps1Two helper functions the others load. Holds the documented path rules.
Test-GuestPath.ps1Four ways to ask whether a path is absolute, over eight realistic paths.
Test-CopyDirection.ps1Ten copies run through the rules, four of them rejected.
Test-GuestPassword.ps1What the binder does with the credential arguments the reference prints.
Test-CopyParameterSet.ps1The two parameter sets, and the message you get for two different mistakes.
Test-GuestCopyReady.ps1Power state and VMware Tools status read together, one verdict per VM.
Invoke-GuestFileCopy.ps1The wrapper. Checks the pair, composes the command, and can run nothing.
Compare-GuestCopy.ps1Hashes the local copy and compares it with the hash from inside the guest.
Remove-GuestCopyLab.ps1Lists everything created here so you can remove the folder in one line.
payload.txt and payload-short.txtA 48 byte file to copy, and a truncated version of it to fail against.

Before you start

Five steps, and the fifth is a note rather than a command. Steps 1 to 4 take under a minute and every later section depends on them.

1. Confirm which PowerShell you are in. pwsh.exe is PowerShell 7 and powershell.exe is Windows PowerShell 5.1. PowerCLI 13.3 supports both on Windows, but on Linux and macOS it supports PowerShell 7 only, and every output block on this page was captured on PowerShell 7.4.6.

# Prints the version and the edition. Core means PowerShell 7, Desktop means
# Windows PowerShell 5.1
$PSVersionTable.PSVersion
$PSVersionTable.PSEdition

2. Create the working folder and move into it. Every script below is saved here, and three of them resolve their neighbours relative to their own location.

New-Item -ItemType Directory -Path 'C:\vmguestlab' -Force | Out-Null
Set-Location 'C:\vmguestlab'

3. Allow local scripts for this window only. The scope matters. Process affects the current session and nothing else, so you are not changing a machine policy to read an article.

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -Force

4. Create the two payload files. Paste this one into the shell rather than saving it. It writes the files with line feed endings on every platform, so the byte counts and hashes it prints are the ones you will see in the verification section.

# Writes the two lab files with LF line endings on every platform, so the byte
# counts and the hashes below match whatever shell wrote them. Set-Content
# would use this machine's line ending and change both.
$lf = [char]10
[System.IO.File]::WriteAllText(
    (Join-Path $PWD 'payload.txt'),
    'agent=zaur-collector' + $lf + 'version=4.2.1' + $lf + 'mode=install' + $lf)
[System.IO.File]::WriteAllText(
    (Join-Path $PWD 'payload-short.txt'),
    'agent=zaur-collector' + $lf + 'version')

# Read both back, so what follows is measured rather than assumed.
'{0,-20}{1,-8}{2}' -f 'File', 'Bytes', 'SHA256'
'{0,-20}{1,-8}{2}' -f ('-'*19), ('-'*7), ('-'*64)
foreach ($f in 'payload.txt', 'payload-short.txt') {
    $i = Get-Item -LiteralPath $f
    $h = Get-FileHash -LiteralPath $f -Algorithm SHA256
    '{0,-20}{1,-8}{2}' -f $i.Name, $i.Length, $h.Hash
}

You should see a two row table. If you wrote the files some other way and the byte counts read 51 and 29 rather than 48 and 28, the line endings are carriage return pairs and none of the hashes further down will match.

File                Bytes   SHA256
------------------- ------- ----------------------------------------------------------------
payload.txt         48      72E2E409B5280D371EF79717361ACC0E9305E068B8A602240DB414C46FD05E42
payload-short.txt   28      C97AB1814010AE5385790B1B6ABF443D1C4200BE2F67DC636D175A74D9C7A337

5. A note on path syntax. Every path on this page is in single quotes. A Windows path contains backslashes, and inside double quotes PowerShell reads some backslash sequences as something else. Single quotes pass the string through exactly as typed.

Note: PowerCLI is needed only for the real copy and for Connect-VIServer. The nine lab files never import it, so you can work through the measured sections on a laptop with no vCenter in reach.

What it does, and the two parameter sets

The cmdlet hands the file to the vSphere API, which hands it to VMware Tools in the guest. Underneath are two operations on the GuestFileManager managed object, InitiateFileTransferToGuest and InitiateFileTransferFromGuest, which is why one cmdlet needs a switch to say which way the bytes go.

The reference publishes the syntax as two parameter sets, one per direction. They are identical apart from the switch, and that switch is mandatory in both.

Copy-VMGuestFile [-Destination] <String> [-VM] <VirtualMachine[]> -GuestToLocal
                 -Source <String[]> [-Force] [-GuestCredential <PSCredential>] ...

Copy-VMGuestFile [-Destination] <String> [-VM] <VirtualMachine[]> -LocalToGuest
                 -Source <String[]> [-Force] [-GuestCredential <PSCredential>] ...

These are the parameters the rest of the page comes back to.

ParameterTypeWhat it is for
-SourceString[]Required. An array, so one call can name several sources. Wildcards allowed only at the end of the path.
-DestinationStringRequired, position 1. A single string, not an array.
-VMVirtualMachine[]Required, position 2. The parameter the reference marks as taking pipeline input by value.
-GuestToLocalSwitchParameterMandatory member of one parameter set. The source is in the guest.
-LocalToGuestSwitchParameterMandatory member of the other set. The destination is in the guest.
-ForceSwitchParameterCreates the directories in the destination path that do not exist yet.
-GuestCredentialPSCredentialGuest OS credentials as one object. The form every call on this page uses.
-GuestUser and -GuestPasswordString and SecureStringThe split form. Read the password type twice: it is not a string.
-HostUser, -HostPassword, -HostCredentialvariousDocumented as needed only against vCenter or ESX earlier than 4.0, or a VIX earlier than 1.10.
-ToolsWaitSecsInt32Seconds to wait for a response from VMware Tools. A non-positive value waits indefinitely.
Note: The Output section of the reference page is empty, and the archived PowerCLI help for the same cmdlet gives its return type as None, so nothing comes back from a successful copy and there is no byte count and no object to inspect.

The direction switch decides which path must be absolute

Two sentences in the reference carry most of the trouble people have with this cmdlet. On -Source: "If the file is on a virtual machine, specifies the absolute file path. Relative file paths are supported only when copying files from a local storage." On -Destination: "If the destination points to a virtual machine, specify the absolute file path. Relative destination paths are supported only when copying files to a local storage."

Neither sentence names a direction, because it does not have to. The direction switch is what decides which of your two paths points at a virtual machine, and therefore which one has to be absolute. The same two strings are a valid copy one way round and an error the other way round.

DirectionPath on the VMMust be absoluteMay be relative
-GuestToLocal-Source-Source-Destination
-LocalToGuest-Destination-Destination-Source

Those rules are short enough to implement, which is the cheapest way to stop guessing. Save the helper file first: the next two scripts load it and will fail without it.

Save this as GuestPathRules.ps1 in C:\vmguestlab. It is loaded by the other scripts and prints nothing when you run it on its own.

<#
    GuestPathRules.ps1

    Two helpers the other scripts in this lab dot-source. Dot-sourcing this
    file defines the functions and prints nothing.

    The rules implemented here are the ones the Copy-VMGuestFile reference
    states for -Source and -Destination. Nothing here talks to vCenter.
#>

function Test-GuestPathShape {
    <#
        Returns Windows, Posix or Relative for a path STRING, without asking
        the client's own file system anything. A guest path has to be judged
        as text, because the client that runs PowerCLI may not be the same
        kind of machine as the guest.
    #>
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Path)

    if ($Path -match '^[A-Za-z]:[\\/]') { return 'Windows' }   # C:\ or C:/
    if ($Path -match '^\\\\[^\\]')      { return 'Windows' }   # \\server\share
    if ($Path -match '^/')              { return 'Posix' }     # /tmp/
    return 'Relative'
}

function Test-GuestCopyPair {
    <#
        Applies the documented -Source and -Destination rules to one copy and
        returns a verdict object. Direction is the switch you would pass to
        Copy-VMGuestFile: GuestToLocal or LocalToGuest.

        Wildcards: the reference says they are allowed "only at the end of the
        source path". This reads that as "only in the last path segment", so a
        wildcard followed by a separator is rejected.
    #>
    param(
        [Parameter(Mandatory)][ValidateSet('GuestToLocal','LocalToGuest')][string]$Direction,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Source,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Destination
    )

    # The direction decides which of the two paths lives on the VM. That one
    # must be absolute; the other one sits on local storage and may be relative.
    $guestSide = if ($Direction -eq 'GuestToLocal') { 'Source' } else { 'Destination' }

    $reasons = New-Object System.Collections.Generic.List[string]

    $srcShape = Test-GuestPathShape -Path $Source
    $dstShape = Test-GuestPathShape -Path $Destination

    if ($guestSide -eq 'Source' -and $srcShape -eq 'Relative') {
        $reasons.Add('Source is on the VM and must be an absolute path')
    }
    if ($guestSide -eq 'Destination' -and $dstShape -eq 'Relative') {
        $reasons.Add('Destination points to the VM and must be an absolute path')
    }

    # Wildcards are a source-only allowance, and only in the last segment.
    $lastSep = [Math]::Max($Source.LastIndexOf('\'), $Source.LastIndexOf('/'))
    $head    = if ($lastSep -ge 0) { $Source.Substring(0, $lastSep) } else { '' }
    if ($head -match '[\*\?]') {
        $reasons.Add('Source has a wildcard before the last path separator')
    }
    if ($Destination -match '[\*\?]') {
        $reasons.Add('Destination may not contain a wildcard')
    }

    $wildcardSource = ($Source -match '[\*\?]')

    [pscustomobject]@{
        Direction      = $Direction
        GuestSide      = $guestSide
        SourceShape    = $srcShape
        TargetShape    = $dstShape
        WildcardSource = $wildcardSource
        Allowed        = ($reasons.Count -eq 0)
        Reason         = if ($reasons.Count -eq 0) { 'matches the documented rules' } else { $reasons -join '; ' }
    }
}

Save this as Test-CopyDirection.ps1 in C:\vmguestlab and run it with .\Test-CopyDirection.ps1.

<#
    Test-CopyDirection.ps1

    Ten copies, each described the way you would type it, run through the
    documented -Source and -Destination rules. Nothing is copied and no
    connection is made: this is the rule check only.
#>

. "$PSScriptRoot/GuestPathRules.ps1"

# Eight of these ten pairs are two spellings of four jobs: the same two paths
# in both directions. The last two carry wildcards.
$cases = @(
    @{ Direction = 'GuestToLocal'; Source = 'C:\inetpub\logs\u_ex260104.log'; Destination = 'C:\vmguestlab\' }
    @{ Direction = 'LocalToGuest'; Source = 'C:\inetpub\logs\u_ex260104.log'; Destination = 'C:\vmguestlab\' }
    @{ Direction = 'GuestToLocal'; Source = 'logs\u_ex260104.log';            Destination = 'C:\vmguestlab\' }
    @{ Direction = 'LocalToGuest'; Source = 'logs\u_ex260104.log';            Destination = 'C:\vmguestlab\' }
    @{ Direction = 'GuestToLocal'; Source = '/var/log/messages';              Destination = 'collected' }
    @{ Direction = 'LocalToGuest'; Source = '/var/log/messages';              Destination = 'collected' }
    @{ Direction = 'LocalToGuest'; Source = '.\payload.txt';                Destination = '/tmp/' }
    @{ Direction = 'GuestToLocal'; Source = '.\payload.txt';                Destination = '/tmp/' }
    @{ Direction = 'GuestToLocal'; Source = 'C:\inetpub\logs\*.log';          Destination = 'C:\vmguestlab\' }
    @{ Direction = 'GuestToLocal'; Source = 'C:\inetpub\*\u_ex260104.log';    Destination = 'C:\vmguestlab\' }
)

'{0,-14}{1,-32}{2,-18}{3,-9}{4}' -f 'Direction', 'Source', 'Destination', 'Allowed', 'Why'
'{0,-14}{1,-32}{2,-18}{3,-9}{4}' -f ('-'*13), ('-'*31), ('-'*17), ('-'*8), ('-'*58)

$allowed = 0
foreach ($c in $cases) {
    $v = Test-GuestCopyPair -Direction $c.Direction -Source $c.Source -Destination $c.Destination
    if ($v.Allowed) { $allowed++ }
    '{0,-14}{1,-32}{2,-18}{3,-9}{4}' -f $v.Direction, $c.Source, $c.Destination, $v.Allowed, $v.Reason
}

''
'{0,-28}{1}' -f 'Pairs checked:', $cases.Count
'{0,-28}{1}' -f 'Allowed by the rules:', $allowed
'{0,-28}{1}' -f 'Rejected by the rules:', ($cases.Count - $allowed)

You should see a ten row table with an Allowed column, then three counts. Read the first eight rows in pairs: they are four copies, each tried in both directions.

Direction     Source                          Destination       Allowed  Why
------------- ------------------------------- ----------------- -------- ----------------------------------------------------------
GuestToLocal  C:\inetpub\logs\u_ex260104.log  C:\vmguestlab\    True     matches the documented rules
LocalToGuest  C:\inetpub\logs\u_ex260104.log  C:\vmguestlab\    True     matches the documented rules
GuestToLocal  logs\u_ex260104.log             C:\vmguestlab\    False    Source is on the VM and must be an absolute path
LocalToGuest  logs\u_ex260104.log             C:\vmguestlab\    True     matches the documented rules
GuestToLocal  /var/log/messages               collected         True     matches the documented rules
LocalToGuest  /var/log/messages               collected         False    Destination points to the VM and must be an absolute path
LocalToGuest  .\payload.txt                   /tmp/             True     matches the documented rules
GuestToLocal  .\payload.txt                   /tmp/             False    Source is on the VM and must be an absolute path
GuestToLocal  C:\inetpub\logs\*.log           C:\vmguestlab\    True     matches the documented rules
GuestToLocal  C:\inetpub\*\u_ex260104.log     C:\vmguestlab\    False    Source has a wildcard before the last path separator

Pairs checked:              10
Allowed by the rules:       6
Rejected by the rules:      4

Six of the ten pairs are allowed and four are rejected. Of the four copies tried in both directions, one is valid both ways and three are valid in exactly one direction. That is the whole point of the section: nothing about the two strings changed, only the switch.

Common mistake: A relative source with -GuestToLocal reads like every other PowerShell copy, which is what makes it easy to type by accident. logs\u_ex260104.log means "relative to my current directory" to you and nothing at all to the guest.

One message for two different mistakes

Because the direction switch is a mandatory member of both parameter sets, leaving it out and passing both are the same kind of failure to the binder. The next script declares a stand-in with the same parameter shape so you can read the messages without a vCenter connection.

Warning: The stand-in is called Copy-GuestFileShim on purpose. A function named Copy-VMGuestFile would shadow the real cmdlet for the rest of your session, and you would spend the afternoon wondering why a copy that reports success never lands.

Save this as Test-CopyParameterSet.ps1 in C:\vmguestlab and run it with .\Test-CopyParameterSet.ps1.

<#
    Test-CopyParameterSet.ps1

    Copy-VMGuestFile has two parameter sets, and the direction switch is the
    mandatory member of each one. This declares a stand-in with the same shape
    and calls it four ways, so the binder's own messages can be read without
    a vCenter connection.

    The stand-in is deliberately NOT named Copy-VMGuestFile. A function with
    that name would shadow the real cmdlet for the rest of the session.
#>

function Copy-GuestFileShim {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string[]]$Source,
        [Parameter(Mandatory, Position = 1)][string]$Destination,
        [Parameter(Mandatory, ParameterSetName = 'GuestToLocal')][switch]$GuestToLocal,
        [Parameter(Mandatory, ParameterSetName = 'LocalToGuest')][switch]$LocalToGuest
    )
    'resolved set: ' + $PSCmdlet.ParameterSetName
}

$src = 'C:\vmguestlab\payload.txt'
$dst = 'C:\Temp\'

function Show-Call {
    param([string]$Label, [scriptblock]$Call)
    $Label
    try {
        # -ErrorAction Stop turns a binding failure into something catchable.
        # Without it the message still appears, but the script keeps going.
        & $Call | ForEach-Object { '  ' + $_ }
    } catch {
        '  error id: ' + $_.FullyQualifiedErrorId
        '  message:  ' + (($_.Exception.Message -split '\r?\n')[0])
    }
    ''
}

Show-Call '1. neither switch' { Copy-GuestFileShim -Source $src -Destination $dst -ErrorAction Stop }
Show-Call '2. both switches'  { Copy-GuestFileShim -Source $src -Destination $dst -GuestToLocal -LocalToGuest -ErrorAction Stop }
Show-Call '3. -GuestToLocal'  { Copy-GuestFileShim -Source $src -Destination $dst -GuestToLocal -ErrorAction Stop }
Show-Call '4. -LocalToGuest'  { Copy-GuestFileShim -Source $src -Destination $dst -LocalToGuest -ErrorAction Stop }

You should see four numbered calls. The first two fail and the last two report which parameter set was resolved. Compare the two error lines before reading on.

1. neither switch
  error id: AmbiguousParameterSet,Copy-GuestFileShim
  message:  Parameter set cannot be resolved using the specified named parameters. One or more parameters issued cannot be used together or an insufficient number of parameters were provided.

2. both switches
  error id: AmbiguousParameterSet,Copy-GuestFileShim
  message:  Parameter set cannot be resolved using the specified named parameters. One or more parameters issued cannot be used together or an insufficient number of parameters were provided.

3. -GuestToLocal
  resolved set: GuestToLocal

4. -LocalToGuest
  resolved set: LocalToGuest

The first two calls return the same error identifier and the same message. The text names neither switch, so it cannot tell you whether you forgot one or passed both. When you see AmbiguousParameterSet from this cmdlet, count the direction switches on the line: the answer is always zero or two.


The credential argument both published examples get wrong

The reference page carries two examples. Here they are as printed, including the line break in the second one.

Copy-VMGuestFile -Source c:\text.txt -Destination c:\temp\ -VM VM -GuestToLocal -GuestUser user -GuestPassword pass2

$vm = Get-VM -Name VM

Get-Item "c:\FolderToCopy\*.*" | Copy-VMGuestFile -Destination "c:\MyFolder" -VM $vm -LocalToGuest -GuestUser -GuestPassword pass2

Broadcom knowledge base article 421428, on copying files out of a non networked VM, prints the same shape: -GuestUser "Administrator" -GuestPassword "Passwd". The parameter table gives -GuestPassword the type SecureString, and PowerShell has no conversion from a string to one. The next script hands those exact argument patterns to a parameter of the documented types and prints what comes back.

Save this as Test-GuestPassword.ps1 in C:\vmguestlab and run it with .\Test-GuestPassword.ps1.

<#
    Test-GuestPassword.ps1

    The reference types -GuestPassword as SecureString. This asks PowerShell
    what happens when a bare string is handed to a parameter of that type,
    and then what happens when the value is converted first.

    Nothing here is a PowerCLI call. The function below stands in for the
    cmdlet's parameter so the binding can be observed without vCenter.
#>

function Use-GuestPassword {
    param(
        [Parameter(Mandatory)][string]$GuestUser,
        [Parameter(Mandatory)][System.Security.SecureString]$GuestPassword
    )
    '{0,-24}{1}' -f 'bound parameter type:', $GuestPassword.GetType().FullName
}

'{0,-24}{1}' -f 'PowerShell version:', $PSVersionTable.PSVersion.ToString()
''

# 1. Exactly what the reference's own example types: a bare string.
'1. -GuestPassword pass2'
try {
    Use-GuestPassword -GuestUser 'user' -GuestPassword 'pass2'
} catch {
    '{0,-24}{1}' -f 'error id:', $_.FullyQualifiedErrorId
    '{0,-24}{1}' -f 'message:', $_.Exception.Message
}
''

# 2. The second example in the reference types -GuestUser with no value and
#    another parameter name straight after it. This is that argument pattern.
'2. -GuestUser -GuestPassword pass2'
try {
    Use-GuestPassword -GuestUser -GuestPassword 'pass2'
} catch {
    '{0,-24}{1}' -f 'error id:', $_.FullyQualifiedErrorId
    '{0,-24}{1}' -f 'message:', (($_.Exception.Message -split '\r?\n')[0])
}
''

# 3. The same password, converted first. -AsPlainText -Force is the usual way
#    to build a SecureString from a literal, and it defeats the point of the
#    type: the password is in the script and in the session history.
'3. -GuestPassword (ConvertTo-SecureString ...)'
$sec = ConvertTo-SecureString 'pass2' -AsPlainText -Force
Use-GuestPassword -GuestUser 'user' -GuestPassword $sec
''

# 4. The form both of the reference's examples avoid and every call in this
#    article uses. Built here from parts so the script needs no prompt.
'4. -GuestCredential (PSCredential)'
$cred = [System.Management.Automation.PSCredential]::new('SRV-PROD-01\Administrator', $sec)
'{0,-24}{1}' -f 'credential type:', $cred.GetType().FullName
'{0,-24}{1}' -f 'user name:', $cred.UserName
'{0,-24}{1}' -f 'password type:', $cred.Password.GetType().Name
'{0,-24}{1}' -f 'password length:', $cred.Password.Length

You should see four numbered cases. The first two print an error identifier and a message; the last two print types. Case 2 is the argument pattern from the second example above, where a parameter name follows -GuestUser instead of a value.

PowerShell version:     7.4.6

1. -GuestPassword pass2
error id:               ParameterArgumentTransformationError,Use-GuestPassword
message:                Cannot process argument transformation on parameter 'GuestPassword'. Cannot convert the value of type "System.String" to type "System.Security.SecureString".

2. -GuestUser -GuestPassword pass2
error id:               MissingArgument,Use-GuestPassword
message:                Missing an argument for parameter 'GuestUser'. Specify a parameter of type 'System.String' and try again.

3. -GuestPassword (ConvertTo-SecureString ...)
bound parameter type:   System.Security.SecureString

4. -GuestCredential (PSCredential)
credential type:        System.Management.Automation.PSCredential
user name:              SRV-PROD-01\Administrator
password type:          SecureString
password length:        5
What the examples typeDocumented typeWhat the binder does
-GuestPassword pass2SecureStringRefuses to transform System.String into System.Security.SecureString
-GuestUser -GuestPassword pass2StringReports a missing argument for -GuestUser and stops there
-GuestPassword $secureSecureStringBinds, and the password is in your script
-GuestCredential $credPSCredentialBinds, and the password came from a prompt
Common mistake: The repair people reach for is ConvertTo-SecureString -AsPlainText -Force, which does bind. It also puts the password in the script file, in your shell history and in any transcript, which is the one thing the SecureString type was there to discourage. Get-Credential costs one prompt per session.
Note: The second example also pipes Get-Item output into the cmdlet while naming -VM on the same line. The reference marks -VM as the parameter that accepts pipeline input by value, not -Source, so piping files in is not the way to name several of them. Pass an array to -Source instead.

Asking this machine about a path on another machine

PowerCLI 13.3 installs on Windows, Linux and macOS, and on Linux and macOS it runs under PowerShell 7. So the client composing the command line is not necessarily the same kind of machine as the guest receiving the file, and the usual way to check a path asks the client.

Four questions, eight paths, one table. The last two lines of the output say which client produced it.

Save this as Test-GuestPath.ps1 in C:\vmguestlab and run it with .\Test-GuestPath.ps1.

<#
    Test-GuestPath.ps1

    Four ways to ask "is this path absolute", over eight path strings of the
    shapes that turn up in a Copy-VMGuestFile command line. Run it on the
    machine that runs PowerCLI. The answers depend on that machine, which is
    the whole point.
#>

. "$PSScriptRoot/GuestPathRules.ps1"

$paths = @(
    'C:\inetpub\logs\u_ex260104.log'
    'C:\vmguestlab\'
    'D:/exports/report.csv'
    '\\SRV-FS-01\exports\report.csv'
    '/var/log/messages'
    '/tmp/'
    'exports\report.csv'
    'report.csv'
)

'{0,-34}{1,-10}{2,-13}{3,-14}{4}' -f 'Path', 'Shape', 'IsPathRooted', 'SplitPathAbs', 'Leaf by .NET'
'{0,-34}{1,-10}{2,-13}{3,-14}{4}' -f ('-'*33), ('-'*9), ('-'*12), ('-'*13), ('-'*20)

foreach ($p in $paths) {
    # Test-GuestPathShape reads the string only. The other three ask this
    # machine, which is a different question than the one the reference asks.
    $shape  = Test-GuestPathShape -Path $p
    $rooted = [System.IO.Path]::IsPathRooted($p)
    $split  = Split-Path -Path $p -IsAbsolute
    $leaf   = [System.IO.Path]::GetFileName($p)
    '{0,-34}{1,-10}{2,-13}{3,-14}{4}' -f $p, $shape, $rooted, $split, $leaf
}

''
'{0,-34}{1}' -f 'This client reports IsWindows:', $IsWindows
'{0,-34}{1}' -f 'Directory separator char:', [System.IO.Path]::DirectorySeparatorChar

The capture below came from a Linux client. On a Windows client the IsPathRooted column and the last two lines read differently, and that difference is the finding rather than a defect: [System.IO.Path] answers for the platform it is running on, which is documented, while the path in your command line belongs to the guest.

Path                              Shape     IsPathRooted SplitPathAbs  Leaf by .NET
--------------------------------- --------- ------------ ------------- --------------------
C:\inetpub\logs\u_ex260104.log    Windows   False        True          C:\inetpub\logs\u_ex260104.log
C:\vmguestlab\                    Windows   False        True          C:\vmguestlab\
D:/exports/report.csv             Windows   False        True          report.csv
\\SRV-FS-01\exports\report.csv    Windows   False        True          \\SRV-FS-01\exports\report.csv
/var/log/messages                 Posix     True         True          messages
/tmp/                             Posix     True         True          
exports\report.csv                Relative  False        False         exports\report.csv
report.csv                        Relative  False        False         report.csv

This client reports IsWindows:    False
Directory separator char:         /
How you askWhat it actually answersUse it for
Test-GuestPathShapeWhat the string looks like, with no reference to any file systemA path on the other machine
[System.IO.Path]::IsPathRootedWhether this platform would call the path rootedA path on the client, in .NET code
Split-Path -IsAbsoluteWhether PowerShell would call the path absolute, provider awareA path on the client, in a script
[System.IO.Path]::GetFileNameThe part after the last separator this platform recognisesOnly when the path is local
Warning: Read the Leaf column on the Linux capture. GetFileName handed back the entire string for the backslash paths, because a backslash is an ordinary character there, and it returned the file name for D:/exports/report.csv because a forward slash is the separator. A script that builds a guest destination by appending a leaf name is silently correct on one client and silently wrong on another.

Wildcards, and the one place they are allowed

The reference allows wildcards on -Source only, and only "at the end of the source path". It adds one condition for the direction that reads out of a guest: copying from a guest to a local directory supports wildcards only on vCenter Server 5.0 and later.

"At the end" is not defined further, so the rule checker reads it as "in the last path segment", the reading that the example in the reference, c:\FolderToCopy\*.*, fits. Rows nine and ten of the run above are the two sides of it.

SourceWhat the rule checker returnsWhy
C:\inetpub\logs\*.logAllowedThe wildcard is in the last segment
C:\inetpub\*\u_ex260104.logRejectedA wildcard before the last separator
C:\inetpub\logs\*AllowedThe whole folder, and folders are supported
a wildcard in -DestinationRejectedThe destination is one path and takes no pattern
Note: The cmdlet name says file and the synopsis says "files and folders". A source that names a directory copies the directory, which is what Broadcom knowledge base article 421428 does with -Source "C:\temp\". Expect a recursive copy, not a single file.

Is the VM ready to be copied into

Guest file transfer needs the VM powered on and VMware Tools running inside it. They are two separate facts with two separate fixes, and the vSphere API documents three values for the Tools field: guestToolsNotRunning, guestToolsRunning and guestToolsExecutingScripts. Only guestToolsRunning means the channel is ready.

Save this as Test-GuestCopyReady.ps1 in C:\vmguestlab and run it with .\Test-GuestCopyReady.ps1.

<#
    Test-GuestCopyReady.ps1

    Guest file copy needs the VM powered on and VMware Tools running inside
    it. Those are two facts and they fail separately, so this reads them
    together and returns one verdict per VM.

    The four rows below are stand-ins with the field names and the field
    values the vSphere API documents, so the script can be run and read
    anywhere. Against a real vCenter, replace $vms with:

        $vms = Get-VM | Select-Object -Property @{n='VMName';e={$_.Name}},
                   PowerState,
                   @{n='ToolsRunningStatus';e={$_.ExtensionData.Guest.ToolsRunningStatus}},
                   @{n='GuestFamily';e={$_.ExtensionData.Guest.GuestFamily}}
#>

# The property is VMName rather than Name. A real VM object has a Name of its
# own, so reusing it on a stand-in makes the two easy to confuse later.
$vms = @(
    [pscustomobject]@{ VMName='SRV-PROD-01'; PowerState='PoweredOn';  ToolsRunningStatus='guestToolsRunning';          GuestFamily='windowsGuest' }
    [pscustomobject]@{ VMName='SRV-PROD-02'; PowerState='PoweredOn';  ToolsRunningStatus='guestToolsNotRunning';       GuestFamily='windowsGuest' }
    [pscustomobject]@{ VMName='SRV-APP-11';  PowerState='PoweredOff'; ToolsRunningStatus='guestToolsNotRunning';       GuestFamily='linuxGuest' }
    [pscustomobject]@{ VMName='SRV-APP-12';  PowerState='PoweredOn';  ToolsRunningStatus='guestToolsExecutingScripts'; GuestFamily='linuxGuest' }
)

'{0,-14}{1,-12}{2,-30}{3,-8}{4}' -f 'VM', 'Power', 'ToolsRunningStatus', 'Ready', 'What to fix first'
'{0,-14}{1,-12}{2,-30}{3,-8}{4}' -f ('-'*13), ('-'*11), ('-'*29), ('-'*7), ('-'*42)

$ready = 0
foreach ($vm in $vms) {
    $powered = ($vm.PowerState -eq 'PoweredOn')
    # Only guestToolsRunning means the channel is up. guestToolsExecutingScripts
    # is a different state and is not the one to copy into.
    $tools   = ($vm.ToolsRunningStatus -eq 'guestToolsRunning')
    $ok      = ($powered -and $tools)
    if ($ok) { $ready++ }

    $fix = if     (-not $powered)                                              { 'power the VM on, then re-read Tools' }
           elseif ($vm.ToolsRunningStatus -eq 'guestToolsExecutingScripts')     { 'Tools is busy: wait, then re-read' }
           elseif (-not $tools)                                                { 'start or install VMware Tools' }
           else                                                                { 'nothing' }

    '{0,-14}{1,-12}{2,-30}{3,-8}{4}' -f $vm.VMName, $vm.PowerState, $vm.ToolsRunningStatus, $ok, $fix
}

''
'{0,-22}{1}' -f 'VMs checked:', $vms.Count
'{0,-22}{1}' -f 'Ready to copy into:', $ready
'{0,-22}{1}' -f 'Not ready:', ($vms.Count - $ready)

You should see one row per VM with a Ready column and a suggested first fix, then three counts. One of the four rows is ready.

VM            Power       ToolsRunningStatus            Ready   What to fix first
------------- ----------- ----------------------------- ------- ------------------------------------------
SRV-PROD-01   PoweredOn   guestToolsRunning             True    nothing
SRV-PROD-02   PoweredOn   guestToolsNotRunning          False   start or install VMware Tools
SRV-APP-11    PoweredOff  guestToolsNotRunning          False   power the VM on, then re-read Tools
SRV-APP-12    PoweredOn   guestToolsExecutingScripts    False   Tools is busy: wait, then re-read

VMs checked:          4
Ready to copy into:   1
Not ready:            3
Note: The four rows are stand-ins carrying the field names and the documented field values, so the script runs anywhere. The comment at the top of the file has the one line that replaces them with real output from Get-VM when you have a connection. Building accurate inventory queries with Get-VM covers that side.
Warning: A VM in guestToolsExecutingScripts is not broken and does not need Tools reinstalled. It is busy, and the right move is to wait and read the field again.

The wrapper you keep

Everything so far goes into one script you can call instead of the cmdlet. It checks the path pair, composes the exact command line, and with -DryRun it stops there. A dry run needs no PowerCLI, no connection and no credentials, which makes it safe to run while you are still arguing about the paths.

This script loads GuestPathRules.ps1 from its own folder, so both files have to sit in the same place.

Save this as Invoke-GuestFileCopy.ps1 in C:\vmguestlab and run it with .\Invoke-GuestFileCopy.ps1 -VMName SRV-PROD-01 -Direction LocalToGuest -Source '.\payload.txt' -Destination 'C:\Temp\' -Force -ToolsWaitSecs 60 -DryRun.

It has mandatory parameters, so the bare file name would only make it prompt.

<#
    Invoke-GuestFileCopy.ps1

    A wrapper that checks the documented path rules before it calls
    Copy-VMGuestFile, and that can compose the command line without running
    it. -DryRun needs no PowerCLI, no connection and no credentials.

    Run it with -DryRun first, every time. The composed line it prints is the
    line that would run.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$VMName,
    [Parameter(Mandatory)][ValidateSet('GuestToLocal','LocalToGuest')][string]$Direction,
    [Parameter(Mandatory)][string]$Source,
    [Parameter(Mandatory)][string]$Destination,
    [System.Management.Automation.PSCredential]$GuestCredential,
    [int]$ToolsWaitSecs,
    [switch]$Force,
    [switch]$DryRun
)

. "$PSScriptRoot/GuestPathRules.ps1"

$verdict = Test-GuestCopyPair -Direction $Direction -Source $Source -Destination $Destination

'{0,-22}{1}' -f 'VM:', $VMName
'{0,-22}{1}' -f 'Direction:', $Direction
'{0,-22}{1}' -f 'Path on the VM:', $verdict.GuestSide
'{0,-22}{1}' -f 'Source shape:', $verdict.SourceShape
'{0,-22}{1}' -f 'Destination shape:', $verdict.TargetShape
'{0,-22}{1}' -f 'Wildcard in source:', $verdict.WildcardSource
'{0,-22}{1}' -f 'Rule check:', $verdict.Reason

if (-not $verdict.Allowed) {
    '{0,-22}{1}' -f 'Composed command:', '(none: the pair was rejected)'
    # A non-zero exit code so a scheduled caller can tell the difference
    # between "copied" and "never attempted".
    exit 2
}

# Build the argument list in the order the reference prints it, so the line
# can be compared against the documentation by eye.
$parts = @(
    'Copy-VMGuestFile'
    "-Source '$Source'"
    "-Destination '$Destination'"
    "-VM '$VMName'"
    "-$Direction"
)
if ($Force) { $parts += '-Force' }
# A non-positive -ToolsWaitSecs means "wait forever" to the cmdlet, so 0 cannot
# be used here as "not specified". Ask the binder whether it was passed at all.
$waitGiven = $PSBoundParameters.ContainsKey('ToolsWaitSecs')
if ($waitGiven) { $parts += "-ToolsWaitSecs $ToolsWaitSecs" }
# The credential is referenced by variable name, never expanded: a composed
# line is something people paste into chat tickets.
$parts += '-GuestCredential $cred'

$line = $parts -join ' '
'{0,-22}{1}' -f 'Composed command:', $line

if ($DryRun) {
    '{0,-22}{1}' -f 'Dry run:', 'nothing was copied and nothing was connected'
    exit 0
}

if (-not (Get-Command -Name Copy-VMGuestFile -ErrorAction SilentlyContinue)) {
    throw 'Copy-VMGuestFile is not available in this session. Import VMware.VimAutomation.Core and connect with Connect-VIServer first.'
}
if (-not $GuestCredential) {
    throw 'A real run needs -GuestCredential. Build one with Get-Credential.'
}

# A hashtable literal evaluates a variable used as a key, so the line below
# adds either GuestToLocal or LocalToGuest to the splat, never both.
$splat = @{
    Source          = $Source
    Destination     = $Destination
    VM              = $VMName
    GuestCredential = $GuestCredential
    $Direction      = $true
}
if ($Force)     { $splat['Force'] = $true }
if ($waitGiven) { $splat['ToolsWaitSecs'] = $ToolsWaitSecs }

# The cmdlet's documented return type is None, so there is nothing to capture
# here. Absence of an error is the only thing the call itself tells you.
Copy-VMGuestFile @splat
'{0,-22}{1}' -f 'Call returned:', 'no object (documented return type is None)'

You should see seven labelled lines, then the composed command, then a line confirming that nothing ran. The composed line is the line that would have run.

VM:                   SRV-PROD-01
Direction:            LocalToGuest
Path on the VM:       Destination
Source shape:         Relative
Destination shape:    Windows
Wildcard in source:   False
Rule check:           matches the documented rules
Composed command:     Copy-VMGuestFile -Source '.\payload.txt' -Destination 'C:\Temp\' -VM 'SRV-PROD-01' -LocalToGuest -Force -ToolsWaitSecs 60 -GuestCredential $cred
Dry run:              nothing was copied and nothing was connected

Now the same wrapper with the pair from the mistake above, a relative source read out of a guest. It composes nothing and exits with code 2, so a scheduled caller can tell "copied" from "never attempted".

.\Invoke-GuestFileCopy.ps1 -VMName SRV-PROD-01 -Direction GuestToLocal -Source 'logs\u_ex260104.log' -Destination 'collected' -DryRun
VM:                   SRV-PROD-01
Direction:            GuestToLocal
Path on the VM:       Source
Source shape:         Relative
Destination shape:    Relative
Wildcard in source:   False
Rule check:           Source is on the VM and must be an absolute path
Composed command:     (none: the pair was rejected)
exit code: 2
Warning: Note how the wrapper handles -ToolsWaitSecs. A non-positive value means "wait indefinitely" to the cmdlet, so zero cannot be used as "not specified". The script asks $PSBoundParameters whether the parameter was passed at all, and passes it on only then. A default of zero would have turned every call into one that can hang.

What the call returns, and how to know the bytes arrived

A successful copy returns nothing. There is no size, no path and no object, so the way to know the file is intact is to ask both sides for a hash and compare them. The script below hashes the copy it can reach and prints the exact line to run inside the guest.

Save this as Compare-GuestCopy.ps1 in C:\vmguestlab and run it with .\Compare-GuestCopy.ps1 -LocalPath 'payload.txt' -GuestPath 'C:\Temp\payload.txt' -GuestHash <hash from the guest>.

<#
    Compare-GuestCopy.ps1

    Copy-VMGuestFile has a documented return type of None, so a call that
    returns quietly has told you nothing about the bytes. This hashes the copy
    you can reach and compares it with the hash of the other side.

    -GuestHash is the value produced inside the guest. The script prints the
    exact line to run there.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$LocalPath,
    [string]$GuestPath = 'C:\Temp\payload.txt',
    [string]$GuestHash,
    [ValidateSet('SHA256','SHA1','MD5')][string]$Algorithm = 'SHA256'
)

if (-not (Test-Path -LiteralPath $LocalPath)) {
    throw "Local file not found: $LocalPath"
}

$local = Get-FileHash -LiteralPath $LocalPath -Algorithm $Algorithm

'{0,-18}{1}' -f 'Algorithm:', $Algorithm
'{0,-18}{1}' -f 'Local file:', $LocalPath
'{0,-18}{1}' -f 'Local bytes:', (Get-Item -LiteralPath $LocalPath).Length
'{0,-18}{1}' -f 'Local hash:', $local.Hash

# The first fragment is double quoted so the path and the algorithm are filled
# in here. The second is single quoted so $_ survives for the guest shell.
$guestCommand = "Get-FileHash -LiteralPath '$GuestPath' -Algorithm $Algorithm | " +
                'ForEach-Object { $_.Hash }'

''
'Run this inside the guest, through Invoke-VMScript:'
'  ' + $guestCommand

if (-not $GuestHash) {
    ''
    '{0,-18}{1}' -f 'Verdict:', 'no -GuestHash supplied, nothing compared'
    exit 0
}

# Hex digests differ only in case between tools, so compare case-insensitively
# and on the trimmed string: a captured value often arrives with whitespace.
$match = [string]::Equals($local.Hash.Trim(), $GuestHash.Trim(),
                          [System.StringComparison]::OrdinalIgnoreCase)

''
'{0,-18}{1}' -f 'Guest hash:', $GuestHash.Trim()
'{0,-18}{1}' -f 'Lengths equal:', ($local.Hash.Trim().Length -eq $GuestHash.Trim().Length)
'{0,-18}{1}' -f 'Verdict:', $(if ($match) { 'MATCH: the copy is byte for byte identical' } else { 'MISMATCH: do not trust this copy' })

if ($match) { exit 0 } else { exit 3 }

Run it first with no -GuestHash to get the guest side line, send that line through Invoke-VMScript, then run it again with the value that came back. The first capture is the matching case.

# A single quoted here-string hands the text over exactly as written, so
# $_ is read by the guest shell rather than expanded by yours. The guest runs
# its own PowerShell, and nothing here depends on guest networking
$guest = @'
Get-FileHash -LiteralPath 'C:\Temp\payload.txt' -Algorithm SHA256 | ForEach-Object { $_.Hash }
'@

Invoke-VMScript -VM 'SRV-PROD-01' -GuestCredential $cred -ScriptText $guest
Algorithm:        SHA256
Local file:       payload.txt
Local bytes:      48
Local hash:       72E2E409B5280D371EF79717361ACC0E9305E068B8A602240DB414C46FD05E42

Run this inside the guest, through Invoke-VMScript:
  Get-FileHash -LiteralPath 'C:\Temp\payload.txt' -Algorithm SHA256 | ForEach-Object { $_.Hash }

Guest hash:       72E2E409B5280D371EF79717361ACC0E9305E068B8A602240DB414C46FD05E42
Lengths equal:    True
Verdict:          MATCH: the copy is byte for byte identical

And the failing case, with the hash of the truncated file standing in for a copy that did not arrive whole. The exit code is 3, which is the one worth checking in a scheduled job.

Algorithm:        SHA256
Local file:       payload.txt
Local bytes:      48
Local hash:       72E2E409B5280D371EF79717361ACC0E9305E068B8A602240DB414C46FD05E42

Run this inside the guest, through Invoke-VMScript:
  Get-FileHash -LiteralPath 'C:\Temp\payload.txt' -Algorithm SHA256 | ForEach-Object { $_.Hash }

Guest hash:       C97AB1814010AE5385790B1B6ABF443D1C4200BE2F67DC636D175A74D9C7A337
Lengths equal:    True
Verdict:          MISMATCH: do not trust this copy
exit code: 3
Result: Two hashes, one comparison, and the copy is either trustworthy or it is not. The Get-FileHash article goes through the comparison itself, including the ways a hex digest comparison reports a good file as bad.

Privileges, and the names you may be told you need

A guest copy is a privileged vSphere operation, and the privilege it needs is not the one that lets you see the VM. Against vCenter Server or ESXi 5.0 and later the reference names one privilege: VirtualMachine.GuestOperations.Modify. The two older names it also lists, VirtualMachine.Interact.ConsoleInteract and VirtualMachine.Interact.GuestControl, belong to versions earlier than 5.0.

The vSphere security documentation lists five guest operations privileges. The one that matters here is described as allowing operations "that involve modifications to a guest operating system in a virtual machine, such as transferring a file to the virtual machine".

Privilege in the clientInternal nameNeeded for a guest copy
Guest operation modificationsVirtualMachine.GuestOperations.ModifyYes, this is the one
Guest Operation QueriesVirtualMachine.GuestOperations.QueryNo, but it is what lists files in the guest
Guest operation program executionVirtualMachine.GuestOperations.ExecuteFor Invoke-VMScript, not for the copy
Guest operation alias queryVirtualMachine.GuestOperations.QueryAliasesNo
Guest operation alias modificationVirtualMachine.GuestOperations.ModifyAliasesNo
Warning: All five are documented as required on the virtual machine object. A role that can power a VM on, or read the folder it sits in, carries none of them, which is why a copy can fail for an account that can plainly see the VM in the client.
Note: The guest credentials and the vSphere credentials are two different accounts and both have to be right. The vSphere account needs the privilege above; the guest account needs permission to write the destination path inside the guest, and SSPI is documented as not supported, so the guest account cannot be inferred from the one you are logged in as.

A Linux guest, and the tools you are not using

Nothing about the cmdlet is Windows specific on the guest side. A Linux guest takes an absolute POSIX destination and the guest account needs write permission on it, which is the usual reason a copy into /opt fails and the same copy into /tmp succeeds.

# The destination is inside the guest, so it is absolute. The source is here
# and may be relative. No flag on this cmdlet can grant the guest account
# permission to write where it is not allowed to
Copy-VMGuestFile -Source '.\payload.txt' -Destination '/tmp/' -VM 'SRV-APP-12' -LocalToGuest -GuestCredential $cred

The tool you would otherwise reach for needs the thing this cmdlet does not: a route to the guest, a listening service and a credential that service accepts.

# Needs sshd running in the guest, port 22 open along the path, and the guest
# reachable from here. All three are exactly what fails on a broken VM
scp ./payload.txt admin@srv-app-12.corp.local:/tmp/
Route inNeeds the guest networkNeeds a service in the guestNeeds vCenter privileges
Copy-VMGuestFileNoVMware ToolsYes
scp or sftpYessshdNo
A mapped shareYesFile and printer sharingNo
An ISO mounted to the VMNoNoneYes
Note: A copy into a Linux guest writes the bytes you sent. A Windows editor that saved your script with carriage returns will produce a shell script the guest cannot run, and the error will be about a missing interpreter rather than about line endings. The SSH cheat sheet has the usual repair.

When it fails: the faults underneath

Each of the two transfer operations on GuestFileManager documents twelve faults, the same twelve in both directions. They are API fault names rather than PowerCLI message text, and these are the six that map onto something you can fix.

FaultDocumented meaningWhere to look
InvalidPowerState"Thrown if the VM is not powered on."The Power column of the readiness check
GuestOperationsUnavailable"Thrown if the VM agent for guest operations is not running."VMware Tools in the guest
InvalidGuestLogin"Thrown if the the guest authentication information was not accepted."The guest account, not the vSphere one
GuestPermissionDenied"Thrown if the operation fails because the guest authentication will not allow the operation."Permissions on the destination path
OperationNotSupportedByGuest"Thrown if the operation is not supported by the guest OS."The guest OS and the Tools version
FileFault"Thrown if there is a file error in the guest operating system."The path itself, and whether it needs -Force

The other six are GuestComponentsOutOfDate, GuestOperationsFault, InvalidState, OperationDisabledByGuest, RuntimeFault and TaskInProgress. Two of them, the out of date components one and the disabled by guest one, point at the Tools configuration rather than at anything in your command line.

Common mistake: Reading InvalidGuestLogin as a vSphere permission problem. It is the guest account. The vSphere side has its own fault for that, and the privilege table above is where to start when the message is about privileges instead.

Hidden gems

Four things in the reference that change how you write the call, and one that changes how you read it.

-Source is an array and -Destination is not. The types are String[] and String, so one call can name several sources into one destination folder. That is the supported way to copy a handful of named files, and it is the thing the second published example tries to do with a pipeline.

# Two named sources, one destination. The array binds to -Source, which takes
# String[]; the destination stays a single path
$files = @('C:\vmguestlab\payload.txt', 'C:\vmguestlab\payload-short.txt')
Copy-VMGuestFile -Source $files -Destination 'C:\Temp\' -VM 'SRV-PROD-01' -LocalToGuest -Force -GuestCredential $cred

-Force creates directories and nothing else. It is documented as creating "the non-existing directories in the specified destination path". It does not overwrite anything you would otherwise be warned about, and it does not relax a permission in the guest.

A non-positive -ToolsWaitSecs waits indefinitely. That is the documented behaviour of zero and of any negative number, which makes zero a bad default in any wrapper you write around the cmdlet.

The host credential parameters are for versions you do not run. -HostUser, -HostPassword and -HostCredential are documented as needed only against vCenter or ESX earlier than 4.0, or with a VIX earlier than 1.10. Older examples pass them anyway, and copying that habit means handing a second set of credentials to a parameter the documentation says you do not need.

Read the cmdlet name as a hint rather than a limit. The synopsis is "copies files and folders", so a source that names a directory is a recursive copy. Check what you pointed at before running it against a log directory that has been rotating for two years.


Where this matters

  • A VM you locked yourself out of. The copy channel does not care that the guest has the wrong subnet mask, so the log that explains the mistake is still reachable.
  • An appliance with VMware Tools and no shell you are allowed to use. The copy channel is still there when SSH is disabled by the vendor, so a support bundle can come out the same way.
  • Seeding a file onto many guests. One credential, one loop, no WinRM and no SSH to arrange first. Get-VM inventory reporting is how you build the list.
  • A network segment you cannot route to. You need a route to vCenter, not to the guest, which is the usual shape of a DMZ or a test network your workstation cannot reach.
  • Collecting evidence before a reboot. Pulling a crash log out of a guest that is about to be restarted, without logging in and changing anything inside it.

Tips and limitations

  • The direction switch is mandatory and there is no default. Leaving it out and passing both produce the same error, so count the switches rather than reading the message.
  • The path that points at the VM must be absolute. Which one that is depends on the switch, not on the parameter name.
  • Wildcards are allowed on -Source only, at the end of the path, and reading out of a guest with a wildcard is documented as needing vCenter Server 5.0 or later.
  • -GuestPassword is a SecureString. Use -GuestCredential with a PSCredential and the problem does not arise.
  • A successful call returns nothing, so verify with a hash on both sides if the file matters.
  • The vSphere account needs VirtualMachine.GuestOperations.Modify on the VM, and the guest account needs write permission on the destination. Both can fail.
  • SSPI is documented as not supported, so the guest credentials are always explicit.
  • PowerCLI 13.3 runs on Windows PowerShell 5.1 and PowerShell 7 on Windows, and on PowerShell 7 only on Linux and macOS. The measurements on this page are from PowerShell 7.4.6.

Clean up the lab

The last script lists everything this page created and confirms the folder holds nothing else. Nothing was installed, no module was imported, no vCenter object was changed and no guest was written to.

Save this as Remove-GuestCopyLab.ps1 in C:\vmguestlab and run it with .\Remove-GuestCopyLab.ps1.

<#
    Remove-GuestCopyLab.ps1

    Lists everything this article created and confirms it is all in one place.
    Nothing was installed, no module was imported, no vCenter object was
    changed and no guest was touched: the lab is nine script files and two
    text files in a single folder.

    The check is relative to this script's own folder, so it reports the same
    thing whatever the folder is called.
#>

$expected = @(
    'GuestPathRules.ps1'
    'Test-GuestPath.ps1'
    'Test-CopyDirection.ps1'
    'Test-GuestPassword.ps1'
    'Test-CopyParameterSet.ps1'
    'Test-GuestCopyReady.ps1'
    'Invoke-GuestFileCopy.ps1'
    'Compare-GuestCopy.ps1'
    'Remove-GuestCopyLab.ps1'
    'payload.txt'
    'payload-short.txt'
)

'{0,-30}{1}' -f 'File', 'Present'
'{0,-30}{1}' -f ('-'*29), ('-'*7)

$present = 0
foreach ($f in $expected) {
    # $PSScriptRoot is the folder holding this script, so the listing is true
    # whether the lab sits on C: or anywhere else.
    $hit = Test-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath $f)
    if ($hit) { $present++ }
    '{0,-30}{1}' -f $f, $hit
}

''
'{0,-30}{1}' -f 'Files listed:', $expected.Count
'{0,-30}{1}' -f 'Files present:', $present
'{0,-30}{1}' -f 'Anything else here:', ((Get-ChildItem -LiteralPath $PSScriptRoot -File).Count - $present)

You should see eleven rows, all True, and a last line reading zero.

File                          Present
----------------------------- -------
GuestPathRules.ps1            True
Test-GuestPath.ps1            True
Test-CopyDirection.ps1        True
Test-GuestPassword.ps1        True
Test-CopyParameterSet.ps1     True
Test-GuestCopyReady.ps1       True
Invoke-GuestFileCopy.ps1      True
Compare-GuestCopy.ps1         True
Remove-GuestCopyLab.ps1       True
payload.txt                   True
payload-short.txt             True

Files listed:                 11
Files present:                11
Anything else here:           0

Then remove the folder from a prompt that is not sitting inside it.

Set-Location 'C:\'
Remove-Item -LiteralPath 'C:\vmguestlab' -Recurse -Force

Official documentation


  • PowerCLI Command Builder: build the connection and inventory commands that come before any guest copy.
  • Hash generator: hash a file in the browser when you want a second opinion on what the guest reported.

Five cheat sheets, one PDF

Subnet masks, PowerShell, Linux commands, HTTP status codes and the ESXi command line - one page each, free to keep. Leave an address and it arrives in a minute.