Is Your Shell Clean? Why .bashrc Output Breaks scp, rsync, SFTP, and Git over SSH

Fix 'Received message too long', 'protocol version mismatch -- is your shell clean?', and 'bad line length character' by finding the startup file that prints over SSH.

If sftp fails with Received message too long 1466264675, rsync stops with protocol version mismatch -- is your shell clean?, or a Git fetch over SSH reports bad line length character, the remote account's shell startup files are almost certainly writing to standard output.

An echo "Welcome to dev-box" in ~/.bashrc is harmless when you log in by hand. It corrupts every program that speaks a protocol over SSH.

Check with one command:

ssh -T dev.example.com true | od -c | head

If that prints anything, the printed bytes are the bug. Make the startup file silent for non-interactive shells, and every tool in this article starts working again.

This guide explains why the output gets into the stream, how to decode the error numbers, how to find the exact line responsible, and how to fix it for bash, zsh, tcsh, and fish. It also covers what to do when you cannot change the server.

The errors and what they have in common

Tool Typical error
sftp, and scp in OpenSSH 9.0+ Received message too long 1466264675, followed by Ensure the remote shell produces no output for non-interactive sessions.
rsync protocol version mismatch -- is your shell clean? and (see the rsync manpage for an explanation), then rsync error: protocol incompatibility (code 2)
Git over SSH fatal: protocol error: bad line length character: Welc
Legacy scp -O The first line of your startup output, printed as if it were an error, and a failed transfer

The rsync wording is GNU rsync's. Other implementations, including the openrsync that recent macOS versions ship, phrase it differently. scp uses the SFTP protocol by default since OpenSSH 9.0, which is why modern scp and sftp fail the same way.

Each of these tools starts a program on the server and talks to it over the SSH channel's standard input and output. Each protocol gives the first bytes a specific meaning. SFTP expects a 4-byte packet length. rsync expects its protocol version number. Git expects a 4-hex-digit packet length. If anything else arrives first, the client misreads it and gives up.

Why your startup file runs at all

When you run ssh host command, sshd does not execute command directly. It runs it through the account's login shell, as $SHELL -c 'command'. That is why ssh host 'ls | wc -l' can use a pipe.

The file-transfer tools are no exception:

  • rsync starts rsync --server ... on the remote host.
  • git fetch and git clone start git-upload-pack '/path/to/repo.git', and git push starts git-receive-pack.
  • Legacy scp starts scp -t to upload or scp -f to download.
  • sftp and modern scp request the sftp subsystem, and sshd launches an external subsystem through the user's shell too. A server configured with internal-sftp is the exception; see the server-side fix below.

The shell in all these cases is non-interactive: no prompt and no terminal. That does not mean it skips its startup files:

Login shell Files read for ssh host command
bash ~/.bashrc, plus /etc/bash.bashrc on Debian-family systems
zsh /etc/zshenv and ~/.zshenv
tcsh and csh /etc/csh.cshrc, then ~/.tcshrc or ~/.cshrc
fish config.fish and the files in conf.d/
dash and other POSIX sh None

The bash row surprises people, because a non-interactive bash normally reads no startup file. The bash manual documents the exception: when bash detects that it was started by a remote shell daemon such as sshd, it reads ~/.bashrc. The exact detection depends on how bash was built, but mainstream Linux distributions behave this way.

Before the shell starts, sshd also runs ~/.ssh/rc or /etc/ssh/sshrc if either exists. The sshd manual says plainly that this file must not produce output on stdout.

Login shells add more files

Some tools start a login shell on purpose, so they get the PATH you set in ~/.profile. Examples include remote development tools that run $SHELL -l -c ... and scripts that call bash -lc. A login shell also reads ~/.profile, ~/.bash_profile, ~/.zprofile, ~/.zlogin, or ~/.login, so output in those files breaks those tools.

What does not break the stream

  • The message of the day and "Last login" line. sshd prints these only for interactive logins without a command.
  • Output on stderr. SSH carries stderr separately from stdout, so it cannot corrupt the protocol. Moving a message to stderr is a valid fix, although rsync and others will display it on every run.

Decode the error to find the culprit

The SFTP number is the first four bytes of your output, read as a big-endian 32-bit length:

printf '%08x' 1466264675 | xxd -r -p; echo
# Welc

1466264675 is 0x57656c63, the ASCII for Welc, the beginning of a "Welcome…" banner. No real SFTP packet is 1.4 GB, so the client stops. Git shows the same four bytes directly in bad line length character: Welc.

rsync reads its version number little-endian, so the bytes would appear reversed, but rsync does not print the number by default. The test in the next section is faster.

Prove it in one command

ssh -T dev.example.com true | od -c | head

-T disables pseudo-terminal allocation, as file transfers do. true produces no output of its own. od -c shows every byte, including invisible ones such as a lone carriage return or an escape sequence that sets the terminal title.

The correct result is no output at all. The rsync manual suggests the equivalent check: run ssh remotehost /bin/true > out.dat and confirm that out.dat is empty.

Illustrative output from an account with a banner:

0000000    W   e   l   c   o   m   e       t   o       d   e   v   -   b
0000020    o   x  \n
0000023

If your tools use a login shell, test that path too. This works when the login shell is bash, zsh, or fish:

ssh -T dev.example.com 'exec "$SHELL" -l -c true' | od -c | head

tcsh only treats -l as a login flag when it is the only argument, so test tcsh accounts by inspecting ~/.login directly.

Find the line that prints

bash

Trace ~/.bashrc with file names and line numbers:

ssh -T dev.example.com 'BASH_ENV=~/.bashrc PS4="+ \${BASH_SOURCE}:\${LINENO}: " bash -x -c true' >/dev/null 2>trace.txt
head -40 trace.txt

The outer shell's own .bashrc output goes to /dev/null. The inner bash -x reads BASH_ENV and traces every command in the file to stderr, which lands in trace.txt. Writing the trace to a file, rather than piping it, keeps the two streams apart in zsh too, where a pipe combined with >/dev/null would also receive stdout. Look for the line that writes output:

+ /home/alice/.bashrc:41: echo 'Welcome to dev-box'

This works when the account's login shell is bash. As root, bash ignores PS4 from the environment; the trace still appears, just without file names.

zsh

zsh's default trace prompt already includes the file and line:

ssh -T dev.example.com 'zsh -x -c true' >/dev/null 2>trace.txt
head -40 trace.txt

Illustrative output:

+/home/alice/.zshenv:12> echo 'Welcome to dev-box'

Any shell

Search the usual files for commands that print. Sending the script on stdin to sh -s means the search works whatever the login shell is:

ssh -T dev.example.com sh -s <<'EOF'
for f in ~/.bashrc ~/.bash_profile ~/.profile ~/.zshenv ~/.zprofile ~/.zlogin \
         ~/.cshrc ~/.tcshrc ~/.login ~/.config/fish/config.fish ~/.ssh/rc \
         /etc/bash.bashrc /etc/zshenv /etc/csh.cshrc; do
  [ -r "$f" ] && grep -nHE '^[^#]*(echo|printf|cat|fortune|figlet|neofetch|fastfetch|uptime|date)( |$)' "$f"
done
EOF

Commands that print without looking like it

The obvious echo is not always the problem. Watch for these:

  • eval "$(ssh-agent)" prints Agent pid 12345, because the generated script ends with an echo. Use eval "$(ssh-agent -s)" >/dev/null.
  • cd with CDPATH set prints the new directory when it finds it through CDPATH. POSIX requires this.
  • pushd and popd print the directory stack in bash and zsh.
  • Version managers can announce themselves. nvm use prints Now using node v… unless you pass --silent.
  • Terminal title sequences such as printf '\033]0;%s\007' "$HOSTNAME" are invisible in a terminal but are still bytes on stdout. od -c reveals them.
  • Greeters such as fortune, neofetch, fastfetch, or a custom MOTD script.

Commands like stty, bind, and set -o vi complain on stderr when no terminal is attached, with messages such as stty: 'standard input': Inappropriate ioctl for device. They do not break transfers, but guard them anyway to keep logs clean.

Fix it

The principle: anything that prints, changes the terminal, or only makes sense with a person at a prompt belongs behind an "is this shell interactive?" check.

bash

Put a guard near the top of ~/.bashrc:

# ~/.bashrc

# Non-interactive setup that remote commands need goes above the guard.
export PATH="$HOME/.local/bin:$PATH"

# Everything below is for interactive shells only.
case $- in
  *i*) ;;
  *) return ;;
esac

echo "Welcome to dev-box"

Debian and Ubuntu ship this guard in the default ~/.bashrc. It often disappears when someone replaces the file with their own dotfiles.

Placement matters. If rsync or git lives in ~/.local/bin and the PATH change is below the guard, transfers fail with rsync: command not found instead. Keep quiet setup that remote commands need above the return.

To guard a single line instead, use [[ $- == *i* ]] && echo "Welcome to dev-box".

zsh

Keep ~/.zshenv to silent exports. Move greetings, prompts, and plugin setup to ~/.zshrc, which zsh reads only for interactive shells. If something must stay in ~/.zshenv, guard it:

if [[ -o interactive ]]; then
  echo "Welcome to dev-box"
fi

tcsh and csh

prompt is set only in interactive shells:

# ~/.tcshrc
if ($?prompt) then
    echo "Welcome to dev-box"
endif

fish

# ~/.config/fish/config.fish
if status is-interactive
    echo "Welcome to dev-box"
end

For a greeting, fish's fish_greeting is the better place, because fish shows it only in interactive sessions.

~/.ssh/rc

Send any messages to stderr with >&2, as the sshd manual requires.

Server side: serve SFTP without a shell

If you administer the server, configure sshd to run SFTP in-process:

# /etc/ssh/sshd_config
Subsystem sftp internal-sftp

Reload sshd afterwards. With internal-sftp, sshd does not start the user's shell for SFTP, so sftp and modern scp no longer depend on startup files. rsync and Git still start through the shell, so this complements fixing the dotfiles rather than replacing it.

Verify the fix

ssh -T dev.example.com true | wc -c            # expect 0
echo pwd | sftp -b - dev.example.com            # expect the remote directory
rsync --dry-run -av ./README.md dev.example.com:/tmp/

If the server hosts Git repositories over SSH, git ls-remote dev.example.com:/srv/git/app.git exercises the Git path. Repeat the login-shell test if any of your tools use one.

When you cannot fix the server

Sometimes the account is not yours to change: a shared jump host, a vendor appliance, a managed build machine. sshd will always run your command through the login shell, so you cannot skip the startup files from the client.

What you can do is make your own scripts robust when they run commands over SSH and parse the output. Print a random marker first, and discard everything before it:

marker="__out_$(openssl rand -hex 8)__"

ssh -T dev.example.com sh -s -- "$marker" > raw-output.txt <<'EOF'
printf '\n%s\n' "$1"
ls -la /srv/app
EOF
exit_code=$?

awk -v m="$marker" 'seen { print } $0 == m { seen = 1 }' raw-output.txt

The login shell runs sh -s with the marker as $1. Startup noise comes first, then a newline in case the noise did not end with one, then the marker, then the real output. The marker is random, so a banner cannot contain it by accident. ssh exits with the remote script's status, saved in exit_code, so check it before trusting the output.

This protects your scripts. It does not help rsync, sftp, or Git, which expect a clean stream from the first byte.

Related reading

Our SSH file transfer guide compares scp, rsync, and SFTP once the channel is clean. The SSH ControlMaster guide explains how to make every one of those commands reuse a single authenticated connection.

If you browse remote files in Agents UI, version 0.12.0's SSH file browser also works with login shells that print output, including csh and tcsh login shells. You can inspect a server before you have cleaned up its dotfiles. Command-line transfers still need the fix above.

Try Agents UI

A native terminal for AI coding agents with persistent sessions, SSH workflows, and built-in editing.