ZSH, tmux, Emacs and SSH: A copy-paste story

Implementing working copy-paste in multiple environments is absurdly difficult. Any of the following reasons complicate matters, but taken together the difficulty rises to a level of complexity not seen since macOS stopped shipping reasonably updated versions of bash.

1 X Window Selection
  • Pasting into a terminal is interpreted literally, meaning an attacker can hide ;sudo do $really_evil_thing; in innocent looking, pasteable HTML with CSS trickery. Therefore, we can’t blindly paste text into a terminal.
  • In a terminal, Emacs doesn’t have access to the clipboard.
  • Emacs has its own independent idea of a clipboard, called a kill ring.
  • tmux has its own independent idea of a clipboard, called paste buffers.
  • zsh has it’s own independent idea of a clipboard, also called a kill ring.
  • There is no clipboard in a remote SSH session because a clipboard is a windowing system concept.
  • There are 3 separate “clipboards” in X11, the primary selection, the clipboard selection, and the obsolete cut buffers. 1 For sanity’s sake, we’ll ignore the differences and use clipboard for everything.
  • tmux in macOS doesn’t have access to the clipboard by default.

We will address each issue separately to develop a unified solution.

Shared functions

We need several functions to unify copy-paste handling across macOS and Linux. I’ve only included the most relevant functions. The rest of the helper functions can be found in my dotfiles, under zsh/functions.

#!/bin/zsh

# Copies data to clipboard from stdin.
function clipboard-copy() {
  emulate -L zsh

  local clipper_port=8377
  local fake_clipboard=/tmp/clipboard-data.txt
  if is-ssh && is-port-in-use $clipper_port; then
    # Pipe to the clipper instance and the fake clipboard.
    tee >(nc localhost $clipper_port) "$fake_clipboard"
    return
  fi

  if ! has-display; then
    # Copy to fake_clipboard.
    > fake_clipboard
    return
  fi

  if is-darwin; then
    pbcopy
  elif is-cygwin; then
    cat > /dev/clipboard
  else
    if command-exists xclip; then
      xclip -in -selection clipboard
    elif command-exists xsel; then
      xsel --clipboard --input
    else
      local message="clipboard-copy: Platform $(uname -s) not supported or "
      message+="xclip/xsel not installed"
      print message >&2
      return 1
    fi
  fi
}

clipboard-copy $@
#!/bin/zsh

# Pastes data from the clipboard to stdout
function clipboard-paste() {
  emulate -L zsh
  # If there's no X11 display, then fallback to our hacky reimplementation.  The
  # data is populated by clipboard-copy.
  if ! has-display; then
    local fake_clipboard=/tmp/clipboard-data.txt
    [[ -e $fake_clipboard ]] && cat $fake_clipboard
    return
  fi

  if is-darwin; then
    pbpaste
  elif is-cygwin; then
    cat /dev/clipboard
  else
    if command-exists xclip; then
      xclip -out -selection clipboard
    elif command-exists xsel; then
      xsel --clipboard --output
    else
      message="clipboard-paste: Platform $GRML_OSTYPE not supported "
      message+="or xclip/xsel not installed"
      print $message >&2
      return 1
    fi
  fi
}

clipboard-paste $@

Problem: Pasting text is interpreted literally in a terminal

When you paste something in a terminal, a terminal will default to interpreting what you pasted the same as entering a sequence of commands. See this site for an example of a posioned clipboard attack. The resulting Hacker News and Reddit discussion are also worth a look.

We want to be able to seww what we pasted without it executing. ZSH has the capability to edit multi-line text entries with the ZSH line editor (ZLE) and widgets. Therefore, we can dump the pasted text into the edit buffer knowing that it won’t be executed.

NOTE: Bracketed paste mode doesn’t seem necessary with this approach but I’m not 100% certain this prevents all clipboard attacks.

#!/bin/zsh

# Pastes the current clipboard and adds it to the kill ring.
function widget-paste-from-clipboard() {
  local paste_data=$(clipboard-paste \
      | remove-trailing-empty-lines \
      | remove-leading-empty-lines)
  zle copy-region-as-kill "$paste_data"
  LBUFFER=${LBUFFER}${paste_data}
}

Now, we need to bind this function in ZSH.

# Gotta catch them all.
bindkey -M emacs '\C-y' widget-paste-from-clipboard
bindkey -M viins '\C-y' widget-paste-from-clipboard
bindkey -M vicmd '\C-y' widget-paste-from-clipboard

Problem: Terminal Emacs lacks clipboard access

In a GUI Emacs, everything is nicely integrated for us. In terminal mode, i.e. emacs -nw, Emacs isn’t linked to any of the X11 libraries. So, in terminal mode, Emacs has no idea how to read or put data on the clipboard. We can enable clipboard access for a terminal Emacs in two steps.

  1. From tmux, identify when we’re pasting into Emacs.
  2. Use emacsclient to call a function with the paste data.

NOTE: This relies on the assumption that Emacs will always run in a tmux session.

For the first step, we need the following shell function on the $PATH.

#!/bin/zsh

# Paste specially into programs that know how to handle paste events.
function tmux-smart-paste() {
  # display-message -p prints to stdout.
  local current_program=$(tmux display-message -p '#{window_name}')
  if [[ $current_program == 'zsh' ]]; then
    # ZSH must have C-y bound to a smart paste for this to work.
    tmux send-keys 'C-y'
  elif [[ ${current_program:l} == 'emacs' ]]; then
    emacsclient --no-wait --alternate-editor=false --quiet \
                --eval "(my:paste-from-emacs-client)" \
                2>&1 > /dev/null
  else
    tmux set-buffer "$(clipboard-paste)"
    tmux paste-buffer
  fi
}
tmux-smart-paste

Next, we bind tmux-smart-paste in tmux.conf to C-y.

bind-key -T root C-y run-shell "tmux-smart-paste"

For step two, we need the following emacs-lisp function.

(defun my:paste-from-emacs-client ()
  "Paste into a terminal Emacs."
  (if window-system
      (error "Trying to paste into GUI emacs.")
    (let ((paste-data (s-trim (shell-command-to-string "clipboard-paste"))))
      ;; When running via emacsclient, paste into the current buffer.  Without
      ;; this, we would paste into the server buffer.
      (with-current-buffer (window-buffer)
        (insert paste-data))
      ;; Add to kill-ring
      (kill-new paste-data))))

NOTE: The terminal Emacs must be running the server for this to work.

Problem: tmux uses paste buffers instead of clipboard

In newer tmux versions, the copy-pipe-and-cancel is just what we need. This only handles the case using a visual selection and using y to yank the selection.

bind-key -T copy-mode-vi 'y' send-keys -X copy-pipe-and-cancel "clipboard-copy"

Problem: tmux under macOS lacks clipboard access

The canonical reference for tmux and macOS integreation is Chris Johnsen’s tmux-MacOSX-pasteboard repo. The problem is that pbpaste and pbcopy do not work under tmux. The problem is solvable with undocumented functions.

  1. Install reattach-to-user-namespace.
    brew install reattach-to-user-namespace
    
  2. Configure tmux to invoke the shell with reattach-to-user-namespace.
    # NOTE: tmux runs commands with 'sh', so the command must be POSIX compliant.
    # That means no ZSH functions. Use executables on PATH instead.
    if-shell '[ "$(uname -s)" = "Darwin" ]' 'source-file ~/.config/tmux/osx.conf'
    
    # Tmux options for OSX.
    
    # Hack to enable pbcopy and pbpaste to work in Tmux.  See
    # https://github.com/ChrisJohnsen/tmux-MacOSX-pasteboard.
    set-option -g default-command 'reattach-to-user-namespace -l zsh'
    

Problem: Remote SSH sesssions lack clipboard access to local session

When you’re SSHed into a remote computer, it would be really nice to copy text from the terminal and make it available on your local computer. Usually, the way people do this is by selecting the text via mouse and invoking copy from the terminal emulator, e.g. iterm2.

We want to be able to copy text from a remote SSH session and have it be available on our local clipboard using normal tmux commands. Clipper is tailor made for this use case because it provides “clipboard access for local and remote tmux sessions.” Once you have clipper running on the remote server and locally, we can send data to it by modifying the clipboard-copy function.

function clipboard-copy() {
  local clipper_port=8377
  if is-ssh && is-port-in-use $clipper_port; then
    # Pipe to the clipper instance.
    nc localhost $clipper_port
    return
  fi
  ### <snip>
}

Most up-to-date code in my dotfiles

The most up-to-date code is in my dotfiles repo. The interesting bits are clipboard-copy, clipboard-paste, Emacs integration and tmux integration.

Bibliography

X Window Selection

Published on by .