Having your favorite commands available over SSH can be very convenient.

I think I talked about this a couple of times before, but I usually work by SSH-ing from my mac into a Linux machine (a rather chunky one, might I add).

While it allows me to work faster when I’m not home and with a poor internet connection, it has some drawbacks too. Two of them are the lack of clipboard integration and the fact that open (or xdg-open) won’t work.

In this post I’ll show you how I got around that. It’s worth nothing that I’ll focus more on a macOS to Linux workflow, and will hereby refer to them as client and host.

Enough said, let’s get to it!

Clipboard sharing

One common way to share the clipboard with the client is using the OSC52 ANSI Sequence.

It’s likely your favorite $EDITOR has a plugin for it, for example: vim and neovim.

But, sometimes what you really want is to run some command | pbcopy and then paste it somewhere, and in which case OSC52 won’t help you - it can copy to the client clipboard, but you can’t read it in the host.

An idea to overcome that is to run the native pbcopy and pbpaste as services on the client, and then RemoteForward them to the host, using nc to talk with them.

To do that, on the client, create these 2 file:

~/Library/LaunchAgents/localhost.pbpaste.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>KeepAlive</key>
	<dict>
		<key>Crashed</key>
		<true/>
		<key>SuccessfulExit</key>
		<false/>
	</dict>
	<key>Label</key>
	<string>localhost.pbpaste</string>
	<key>ProcessType</key>
	<string>Background</string>
	<key>ProgramArguments</key>
	<array>
		<string>/usr/bin/pbpaste</string>
	</array>
	<key>Sockets</key>
	<dict>
		<key>Listener</key>
		<dict>
			<key>SockNodeName</key>
			<string>127.0.0.1</string>
			<key>SockServiceName</key>
			<string>2225</string>
		</dict>
	</dict>
	<key>inetdCompatibility</key>
	<dict>
		<key>Wait</key>
		<false/>
	</dict>
</dict>
</plist>

~/Library/LaunchAgents/localhost.pbcopy.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>KeepAlive</key>
	<dict>
		<key>Crashed</key>
		<true/>
		<key>SuccessfulExit</key>
		<false/>
	</dict>
	<key>Label</key>
	<string>localhost.pbcopy</string>
	<key>ProcessType</key>
	<string>Background</string>
	<key>ProgramArguments</key>
	<array>
		<string>/usr/bin/pbcopy</string>
	</array>
	<key>Sockets</key>
	<dict>
		<key>Listener</key>
		<dict>
			<key>SockNodeName</key>
			<string>127.0.0.1</string>
			<key>SockServiceName</key>
			<string>2224</string>
		</dict>
	</dict>
	<key>inetdCompatibility</key>
	<dict>
		<key>Wait</key>
		<false/>
	</dict>
</dict>
</plist>

Then, start them with:

launchctl start localhost.pbcopy
launchctl start localhost.pbpaste

Now, we can RemoteForward ports 2224 and 2225 to our host:

ssh -R localhost:2224:127.0.0.1:2224 \
  -R localhost:2225:127.0.0.1:2225 \
  user@server

Then, we can test it with nc:

echo "example" | nc -q1 localhost 2224
nc -q1 -d localhost 2225

Okay, this works, and pressing <Cmd>-V should give you the same output. Go ahead, try it!

Making it nicer To make it easier to use, I created my own pbcopy and

pbpaste scripts, which I add to my $PATH with a higher precedence than /usr/bin.

Here are their contents:

#!/bin/bash
# ~/.bin/pbcopy
set -eo pipefail
if test -n "$SSH_TTY"; then
	nc -q1 localhost 2224
elif test "$(uname)" = Darwin; then
	/usr/bin/pbcopy
else
	xsel --clipboard --input
fi
#!/bin/bash
# ~/.bin/pbpaste
set -eo pipefail
if test -n "$SSH_TTY"; then
	nc -q1 -d localhost 2225
elif test "$(uname)" = Darwin; then
	/usr/bin/pbpaste
else
	xsel --clipboard --output
fi

On both scripts, I check if I’m on a SSH connection before using nc. If I’m not, I use either the native pbcopy and pbpaste or Linux’ xsel.

This way I can easily pipe into and from pbcopy and pbpaste on my host, sharing the clipboard with the client!

This assumes you add ~/.bin to your $PATH, for example, with:

export PATH="$HOME/.bin/:$PATH"

Making open work

open (or xdg-open) is used by many tools and scripts to, among other things, open a browser at a given URL. It might have surprised you the first time you tried it over SSH though, as it would have given you an error like this:

Error: no DISPLAY environment variable specified

Which makes sense, as there is indeed no $DISPLAY available, and even it had, it wouldn’t open in the client, but rather in the host.

To fix that, we’ll do the same thing we did with the clipboard: run a service on the client, and using nc on the host.

There’s no way (that I’m aware of) of running macOS’ open as a service, so I wrote a small Go service that listen to income connections, and run open args. You can grab it here.

Go to the repository

It works on both Linux and macOS, and on macOS you can install it with:

brew install caarlos0/tap/xdg-open-svc

If you install it through brew, it’ll create the service for you automatically, otherwise you’ll need to create it yourself. See the config file bellow.

~/Library/LaunchAgents/localhost.xdg-open.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>KeepAlive</key>
	<true/>
	<key>Label</key>
	<string>localhost.xdg-open</string>
	<key>ProgramArguments</key>
	<array>
		<string>/path/to/bin/xdg-open-svc</string>
	</array>
	<key>RunAtLoad</key>
	<true/>
	<key>StandardErrorPath</key>
	<string>/Users/USERNAME/.cache/xdg-open-svc.log</string>
	<key>StandardOutPath</key>
	<string>/Users/USERNAME/.cache/xdg-open-svc.log</string>
</dict>
</plist>

PS: make sure to change the paths above, namely the path to the binary and to the log files.

Then, start the service:

launchctl start localhost.xdg-open

We can then forward it to our host as well:

ssh -R localhost:2224:127.0.0.1:2224 \
  -R localhost:2225:127.0.0.1:2225 \
  -R localhost:2226:127.0.0.1:2226 \
  user@server

Now, you should be able to test it with nc:

echo "https://carlosbecker.com" | nc localhost 2226

Making it nicer

As with the clipboard, we can make our own open. Here’s the contents of mine:

#!/bin/bash
# ~/.bin/open
# ~/.bin/xdg-open
set -eo pipefail
if test -n "$SSH_TTY"; then
	echo "$@" | nc -q1 localhost 2226
else
	/usr/bin/open $*
fi

This way, open works, and if you ln it to xdg-open, so does xdg-open. And with that, other CLIs should be able to just call open and it’ll open on the client machine.

SSH Config

To avoid having to type all those -R parameters every time, you can add this to your ~/.ssh/config:

Host server
  User user
  RemoteForward [localhost]:2224 [127.0.0.1]:2224
  RemoteForward [localhost]:2225 [127.0.0.1]:2225
  RemoteForward [localhost]:2226 [127.0.0.1]:2226

So you can simply ssh server and it’ll always do the RemoteForwards for you.

A note about ncat

While writing this, GitHub user @pbnj opened an issue pointing out this is also possible with ncat. That binary is usually distributed with the nmap package, and you can use it instead of xdc-open-svc if you want.

Main differences would be that xdg-open-svc logs everything, listens on localhost instead of 0.0.0.0, and if you install it with Homebrew, it’ll set the service up for you automatically. Other than that, they should work the same.

I use nix, btw

If you use nix and home-manager, you can copy my services from here.

You can also install xdg-open-svc using my NUR.

Also feel free to check my nix dotfiles.

That’s it! Hope you enjoyed this and that it helps you somehow. Cheers!