Move your automation to a headless server and browser control disappears. The tool talks to a
browser extension over a socket in /tmp. No socket, no browser. SSH can forward a
UNIX domain socket, not just a TCP port. That is the whole trick. Your desktop's socket
shows up on the server at the same path.
You cannot forward what you have not found. The instinct is to look for a listening port. That instinct is
wrong here. Local integrations use a UNIX socket in /tmp or $XDG_RUNTIME_DIR
instead, and the usual commands never show it:
# TCP listeners. Shows NOTHING for a socket-based integration
ss -lnt
# UNIX domain sockets. This is where local IPC actually lives
ss -lxp | grep -i <toolname>
Run the second one while the integration is connected and working. You want a LISTEN entry with
a path and an owning process. That gives you the socket to forward. It also tells you which side
listens. The listener is the server. The other side dials in. Get that backwards and you forward the
wrong direction.
OpenSSH's -L and -R both take socket paths in place of ports. Pick based on where
the listener sits and which machine can SSH to which. Most homelab desktops run no SSH server. So the desktop
initiates. That is -R, publishing its local socket onto the remote box:
# ON THE DESKTOP, with the browser + extension running
SOCK=$(ls /tmp/<tool-bridge-dir>/*.sock | head -1)
ssh -N -o ExitOnForwardFailure=yes -R "$SOCK:$SOCK" user@headless-box
Forward it to the identical path on the far side. The remote process looks for that exact filename. It has no idea the socket is a tunnel. Tools on the remote side then report the browser as local, because from where they sit it is.
Three requirements on the receiving box. All easy to miss:
AllowStreamLocalForwarding yes in sshd_config. It is the default. Confirm it
with sudo sshd -T | grep streamlocal instead of assuming.0700. SSH creates the socket.
It does not create the directory holding it.StreamLocalBindUnlink defaults to no. So a leftover socket file from a
dropped connection blocks the next one from binding. Delete the file before each connect. This is
the top reason a tunnel that worked once will not come back.python3 - <<'EOF'
import socket
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.settimeout(4)
s.connect("/tmp/<tool-bridge-dir>/<name>.sock"); print("connect: OK")
s.sendall(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
print(s.recv(256) or "clean EOF - reachable")
EOF
A clean connect followed by the far side closing is good. Something real accepted it and rejected your
protocol guess. A refused connection means the tunnel is broken, not the app.
Many tools decide at process startup whether an integration exists, and register it then. Make the socket reachable after that and a runtime "reconnect" command never picks it up. The running process keeps insisting nothing is installed. Your tunnel can be perfectly healthy the whole time.
So if a status screen calls the integration disabled while you can prove the socket is live, restart the process with the flag that enables it. Do not toggle it from inside. Look for a resume flag so you keep your working state.
A hand-run ssh -N dies with its terminal. A hardcoded socket path goes stale the moment the
broker restarts. A small supervisor script fixes both. Discover the socket at start. Poll for the filename
changing. Rebuild the tunnel when it does.
while :; do
SOCK=""
while [ -z "$SOCK" ]; do
SOCK=$(ls -1t "$DIR"/*.sock 2>/dev/null | head -1)
[ -z "$SOCK" ] && sleep 5 # browser not running yet; wait, don't exit
done
# clear the stale remote socket (StreamLocalBindUnlink defaults to no)
ssh -o BatchMode=yes "$REMOTE" "mkdir -p '$DIR' && chmod 700 '$DIR' && rm -f '$SOCK'" || { sleep 5; continue; }
ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
-o BatchMode=yes -R "$SOCK:$SOCK" "$REMOTE" &
SSHPID=$!
while kill -0 "$SSHPID" 2>/dev/null; do # restart if the broker pid changes
[ "$(ls -1t "$DIR"/*.sock 2>/dev/null | head -1)" != "$SOCK" ] && { kill "$SSHPID"; break; }
sleep 5
done
wait "$SSHPID" 2>/dev/null
done
Wrap that in a user service, not a system one. The socket lives in the user's session. It only exists while they are logged in with the browser running:
[Unit]
Description=Browser bridge tunnel to headless box
After=network-online.target
[Service]
Environment=REMOTE=user@headless-box
ExecStart=%h/.local/bin/bridge-tunnel.sh
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
systemctl --user enable --now bridge-tunnel.service
BatchMode=yes with a
passphrase-less key dedicated to this. Or point the unit at your agent with
Environment=SSH_AUTH_SOCK=.... A passphrase-protected key that works when you test it by hand
fails silently under systemd. Your interactive shell had an agent. The service does not.
Skip loginctl enable-linger here, and skip it on purpose. Lingering keeps user services running
after logout. That sounds like robustness. It is wrong for this. The socket does not exist when you are
logged out. All lingering buys you is a service retrying into nothing.
This hands a remote machine the browser holding every session you are logged into. Cloud provider consoles. Your router or firewall UI. Hypervisor. Source control. Email. There is no sandbox in the middle. An action taken through that tunnel carries exactly the authority you have sitting at the desk.
That can be a fine trade for a box you already trust with SSH keys to your infrastructure. Make the decision
on purpose. Enable the service if you want the capability standing. Or leave it disabled and run
systemctl --user start only for the sessions that need it. A separate browser profile for
automation is the stronger option. You get the capability without handing over your day to day logins.
If the extension path will not work, use the browser's own remote-debugging protocol. Launch Chrome with
--remote-debugging-port against a dedicated profile directory. Reverse-forward that TCP port.
Drive it with any CDP client, such as Playwright's connectOverCDP. It needs no extension at all.
The trade is that you script the browser directly instead of using the higher-level tool surface the
extension gives you.