1

I'm working on an Azure DevOps pipeline Classic UI that deploys a software package to multiple Windows devices registered in Deployment Groups.

The flow:

  1. Copying SSH Keys:

    • We copy a private key file (id_rsa) and other supporting files from a deployment package into the target machine's .ssh folder:
      C:\path1\path2\path3 files\path4\path5\.ssh\id_rsa
      
    • Permissions are set (FullControl for the intended user) and the file is confirmed to exist.
  2. Attempting SSH Connection:

    • Using PowerShell, we run an SSH command to connect to a required endpoint for the software (endpoint name masked for security).
    • The SSH command should prompt for a fingerprint, which we expect to accept (i.e., first time connection should add to known_hosts).

The problem:

  • The SSH command consistently fails:
    Warning: Identity file C:\path1\path2\path3 not accessible: No such file or directory.
    ssh: Could not resolve hostname files\\path4\\path5\\.ssh\\id_rsa: No such host is known.
    
  • The id_rsa file does exist confirmed by script and manual inspection.
  • The SSH command seems to split the argument at the space in "path3 files", treating the rest as a hostname.

What we've tried:

  • Quoting the Key Path:
    • Tried "C:\path1\path2\path3 files\path4\path5\.ssh\id_rsa" with explicit quotes in the SSH command.
  • PowerShell Array Arguments:
    • Passed arguments to Start-Process as an array, e.g.:
      $argList = @(
          "-i", $sshKeyPath,
          "-T",
          "-o", "StrictHostKeyChecking=accept-new",
          "$remoteUser@$hostEndpoint",
          "exit"
      )
      Start-Process -FilePath $sshExe -ArgumentList $argList
      
  • Running via cmd.exe:
    • As a workaround, used cmd.exe /c "ssh ..." so the Windows shell parses the arguments, e.g.:
      $sshCmd = "ssh -i `"$sshKeyPath`" -T ... $remoteUser@$hostEndpoint exit"
      Start-Process -FilePath "cmd.exe" -ArgumentList "/c $sshCmd"
      
  • Permissions:
    • Ensured the target user has FullControl on the key file and parent directory.

Expected Result:

  • SSH should prompt for fingerprint acceptance (first connection) and complete the handshake.
  • The connection should succeed using the provided key.

Actual Result:

  • SSH always fails, reporting the key file path is not accessible or that it cannot resolve part of the path as a hostname.

Questions:

  1. Why is SSH not correctly parsing the path to the key file, even when quoted?
  2. Is there a recommended PowerShell practice for reliably passing a path with spaces to OpenSSH on Windows from a pipeline context?
  3. Are there additional tricks for handling key acceptance/fingerprints in a non-interactive deployment scenario?
  4. Are there alternative ways to run SSH from PowerShell that avoid argument splitting issues?

Extra context:

  • The pipeline is running as SYSTEM.
  • All sensitive names, endpoints, and product names are intentionally masked.

Any help or guidance is appreciated!

1
  • Quoting parhs with spaces is a quite common issue, other things you might try is using single quotes (') or use the 8.3 name, see also: Spaces cause split in path with PowerShell. Commented Oct 15 at 8:16

2 Answers 2

0

tl;dr
Invoke external commands directly using the call operator &, and embed literal double quotes around variables passed as parameters:

& 'S:\ome\path\acme.exe' 'some static param' "`"$($someVariable)`""

This will get you on the safe side.


With Start-Process, the ArgumentList will basically be joined with a space, and then passed as-is to the program. So this:

$argList = @(
    "-i", $sshKeyPath,
    "-T",
    "-o", "StrictHostKeyChecking=accept-new",
    "$remoteUser@$hostEndpoint",
    "exit"
)
Start-Process -FilePath $sshExe -ArgumentList $argList

will translate to

"C:\Path\to\ssh.exe" -i C:\path1\path2\path3 files\path4\path5\.ssh\id_rsa -T -o StrictHostKeyChecking=accept-new remoteUser@hostEndpoint exit

You would need to enclose literal double quotes around all parameters that might contain spaces or special characters.
You would also need to use the -Wait parameter for Start-Process, since Start-Process will by default start the process in the background, so the script will not wait for ssh.exe to finish.
So this should work if you insist on using Start-Process:

$argList = @(
    '-i', "`"$($sshKeyPath)`"",
    '-T',
    '-o', 'StrictHostKeyChecking=accept-new',
    "`"$($remoteUser)@$($hostEndpoint)`"",
    'exit'
)
Start-Process -FilePath $sshExe -ArgumentList $argList -Wait

This would result in

"C:\Path\to\ssh.exe" -i "C:\path1\path2\path3 files\path4\path5\.ssh\id_rsa" -T -o StrictHostKeyChecking=accept-new "remoteUser@hostEndpoint" exit

But it's called PowerShell for a reason: it's a shell as well as a scripting language, so command line tools can just be invoked like in good ole Batch, there's no need for Start-Process, especially if you need to wait for the command anyway.
The following uses splatting (note the @ instead of $ in front of the variable name) for the existing parameter array, but you could just as well pass a single command line.
You'll still have to enclose literal double quotes around parameters that might contain special characters. When invoked directly, though, PowerShell will automatically add double quotes around variables(!) where the value contains a space (but not if it contains special characters, so that's not much of a benefit).
The & is the call operator. It's technically not required if the path to the program is a literal (that is, not in a variable) and not enclosed in quotes (net.exe, C:\Windows\system32\net.exe), and does not contain spaces or special characters, so for consistency's sake, it's recommended to use it always (and also to include the extension to make it obvious that an external program is called, not a function, scriptblock, or alias (note, for example, sc.exe and the alias sc for Set-Content).

$argList = @(
    '-i', "`"$($sshKeyPath)`"",
    '-T',
    '-o', 'StrictHostKeyChecking=accept-new',
    "`"$($remoteUser)@$($hostEndpoint)`"",
    'exit'
)
& $sshExe @argList

This would result in the same command line as with Start-Process, but you'll get the -Wait for free, and you could also assign the output directly to a variable:

"C:\Path\to\ssh.exe" -i "C:\path1\path2\path3 files\path4\path5\.ssh\id_rsa" -T -o StrictHostKeyChecking=accept-new "remoteUser@hostEndpoint" exit

Yes, command line tools are a bit of a mess, but most of the problems can be resolved using the tl;dr way from above.

A detailed article on this, for example: PowerShell and external commands done right

Sign up to request clarification or add additional context in comments.

3 Comments

PowerShell will automatically quote arguments that have a space if you call them directly so you don't need to do '-i', ""$($sshKeyPath)"". You can just do '-i', $sshKeyPath. It is just Start-Process -ArgumentList where the arguments need to be manually quoted.
That's what I already wrote above, but again: PS doesn't do that for special characters that still might require quoting (like <>|^&%, and instead of thinking long and hard about whether in one special case the enclosed quotes might not be required, it's better to get used to doing it consistently.
None of those require quoting when invoking an exe in pwsh. It might need if you were calling cmd.exe and needed to escape it for that shell but with ssh.exe -i $pathToKey pwsh will automatically quote the argument when calling ssh.exe if $pathToKey contains any whitespace. By doing ``ssh.exe ""C:\path with space""` may or may not work depending on your pwsh version. 5.1 works because it doesn't escape " and leaves it as it but 7.x won't because it escapes the " literals with \". So you are right about Start-Process needing the quotes in the value but not calling it directly.
0

Do not use Start-Process for your ssh call:

  • Since your intent is to synchronously execute a console application in the current console (terminal), call it directly, which also allows you to capture output directly and to later query the exit code via the automatic $LASTEXITCODE variable; see next section.

  • Except in unusual circumstances, such as needing to launch the process in a new window or running with a different user identity, Start-Process is the wrong tool; see this answer and this guidance.

  • Additionally, as an aside:

    • Start-Process has a long-standing bug, which unfortunately requires use of embedded double-quoting around arguments that contain spaces, e.g. -ArgumentList '-foo', '"bar baz"', because the string values of the elements passed to the -ArgumentList parameter are blindly joined as-is.

      • It is therefore often simpler to encode all arguments in a single string, e.g. -ArgumentList '-foo "bar baz"', because the situational need to use embedded double-quoting is then obvious. See this answer for details.
    • Thus, to make your Start-Process call work (leaving aside that without -NoNewWindow the new process executes in a new console window and without -Wait the call is asynchronous), you'd have to formulate your array as follows:

      $argList = @(
          # Note the need for *embedded* "..." around the key-file path
          "-i", "`"$sshKeyPath`"",
          "-T",
          "-o", "StrictHostKeyChecking=accept-new",
          "$remoteUser@$hostEndpoint",
          "exit"
      )
      # Note: Executes in a new window, asynchronously.
      Start-Process -FilePath $sshExe -ArgumentList $argList
      

Instead, use direct invocation:

  # Directly invokes ssh.exe, using the $argList array as defined in your question.
  # Execution is synchronous, in the same console window, and output can be captured.
  & $sshExe $argList  # or: @argList

  # You can now query $LASTEXITCODE for the process exit code.
  • Note:

    • Because your executable path is specified via a variable, direct invocation requires use of the &, the call operator; the same would apply if a quoted path were specified; by contrast, use of & is optional with literal, unquoted executable names (or paths), e.g. ssh.exe; see this answer for background information.

    • When calling an external program (executable) such as ssh.exe, passing an array as-is causes its elements to be passed as individual arguments on the process command line constructed behind the scenes, which is an implicit form of PowerShell's parameter splatting; you can optionally make it explicit by using @ rather than $ to reference the variable, i.e. as @argList; when calling PowerShell-native commands, splatting always requires the @ sigil.

    • Unlike with Start-Process, there is no need to use embedded double-quoting around array elements that have embedded spaces, because PowerShell automatically encloses values that contains spaces in double quotes behind the scenes when it constructs the process command line behind the scenes on Windows.

      • In fact, any attempt to use embedded double-quoting will break calls in PowerShell (Core) 7, given that embedded " chars. are now - correctly - passed escaped as \" on the process command line; conversely, in Windows PowerShell (the legacy, ships-with-Windows, Windows-only edition of PowerShell whose latest and final version is 5.1), the fact that they are passed as-is actually relies on fundamentally broken handling of arguments with embedded double quotes, as explained in this answer.

      • There is an edge case, however:

        • PowerShell - justifiably - bases its decision as to whether an argument value needs double-quoting on the behind-the-scenes process command line strictly on whether the value contains spaces or not. Thus, an argument with, say, verbatim value a|b is placed unquoted on the process command line, despite containing a character such as | that would be considered a metacharacter in a shell context. However, note that a process command line on Windows is not a shell context.

        • This runs afoul of the flawed design of cmd.exe in the context of direct invocation of a batch file (*.cmd, *.bat): cmd.exe inappropriately parses the process command line as if it had been called from inside a cmd.exe session, which causes unquoted arguments such as a|b to break.

        • GitHub issue #15143 was a feature request to make PowerShell accommodate this design flaw (among other accommodations), by also automatically double-quoting space-less arguments behind the scenes if they contain cmd.exe metacharacters in batch-file calls; sadly, the proposal was rejected.

        • Because the PowerShell 7 fix for the broken handling of embedded double quotes in external-program invocations by default selectively retains the old, broken behavior for specific executables / file-name extensions - due to the $PSNativeCommandArgumentPassing preference variable defaulting to 'Windows' - you can actually continue to employ the otherwise broken embedded double-quoting technique when calling batch files; using the example above, you can pass '"a|b"'.
          An alternative, which would also work if $PSNativeCommandArgumentPassing is set to 'Standard', is to pass 'a^|b', i.e. to embed cmd.exe's escape character.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.