Why bash -c 'cd / ; pwd' fails when it is run via SSH

Solution 1:

Preliminary note

Here I will use the words local and remote to distinguish what happens on the SSH client side and the SSH server side respectively. Forget about the fact your SSH server is localhost, i.e. "local"; treat it as a coincidence. In general an SSH server is remote.


Analysis

ssh is able to build code for remote execution from multiple arguments you provide, but this code is not passed as an array. It's passed as a single string. It doesn't matter if the arguments are

  • bash, -c, cd / ;pwd
  • bash, -c, cd, /, ;pwd
  • bash -c, cd, / ;pwd
  • bash -c cd / ;pwd

the resulting string is in each case bash -c cd / ;pwd. The string will be interpreted by a remote shell (a shell spawned by the SSH server). Because of ; the remote shell will recognize two commands. It will split them to words as bash, -c, cd, /; and then (as a separate command) pwd. This will run bash executing cd (note: not even cd /) which cannot change the state of the parent shell, so it's a no-op (in some quirky setup it can throw an error, being a no-op nevertheless); and then pwd totally independent from the previous command.


Solution

The string you want ssh to use is different than the above, it's bash -c "cd / ;pwd". To get it you need to make the double-quotes survive the quote removal phase of the local shell (where you type the ssh … command). You need to quote the quotes or to escape them. Quoted or escaped quotes will not protect ; from being interpreted locally as a command terminator/separator, so it has to be quoted or escaped too:

ssh localhost 'bash -c "cd / ;pwd"'   # this one you have already discovered
#or
ssh localhost bash -c \"cd / \;pwd\"

(These are not the only possible local commands that generate the desired remote string.)

Note in the former case there is exactly one argument that builds the resulting string; in the latter case the string is created by ssh from multiple local arguments. When I need to quote or escape anything, I prefer crafting a single argument. If the desired remote command is static (I mean if it does not depend on local expansion, i.e. local variables or such) then crafting such local argument is algorithmic and not that hard, it can be automated in Bash.


Side note

Running bash -c via ssh may be unnecessary. Whatever string your ssh builds, it will be interpreted by some shell (the user's login shell) on the remote side. The shell may or may not be Bash. If you deliberately want Bash to interpret some code (e.g. because the code contains bashisms) then bash -c is useful. A shell able to run bash -c … should be able to interpret cd / ;pwd as you expect, so in this case bash -c is not needed.

You tried ssh localhost "cd / ;pwd" and it worked. It passed cd / ;pwd to your remote shell, Bash or not. It was less troublesome than the version with bash -c.

I understand cd / ;pwd is just an example. In general the code may require Bash. OTOH in general it may include quotes. My point is there are two or three shells and each will interpret quotes and such; they are:

  1. the local shell prior to spawning ssh,
  2. the remote shell the SSH deamon spawns
  3. and in case of bash -c this exact bash.

The greater number of stacked shells, the more complex quoting and/or escaping is required. bash -c comes with a price: it imposes additional complexity of the local command. If you know the login shell on the remote side can correctly interpret the code you want to run then bash -c is most likely a burden only.