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:
rsyncstartsrsync --server ...on the remote host.git fetchandgit clonestartgit-upload-pack '/path/to/repo.git', andgit pushstartsgit-receive-pack.- Legacy
scpstartsscp -tto upload orscp -fto download. sftpand modernscprequest thesftpsubsystem, and sshd launches an external subsystem through the user's shell too. A server configured withinternal-sftpis 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
rsyncand 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)"printsAgent pid 12345, because the generated script ends with anecho. Useeval "$(ssh-agent -s)" >/dev/null.cdwithCDPATHset prints the new directory when it finds it throughCDPATH. POSIX requires this.pushdandpopdprint the directory stack in bash and zsh.- Version managers can announce themselves.
nvm useprintsNow 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 -creveals 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.