[BUG] Workbench connection: "RPC transport closed during capability handshake" on Ubuntu — claude-ssh --serve daemon killed by systemd-logind (KillUserProcesses=yes)
Bug: claude-ssh --serve daemon killed by systemd-logind on Linux when KillUserProcesses=yes (the systemd default)
Severity: Workbench/SSH-remote feature is broken out-of-the-box on stock Ubuntu and any other distro shipping the upstream systemd default.
Affects:
- Claude desktop app 1.4758.0 on macOS 26.5 (build 25F5058e), arm64
- Internal SDK 2.1.119
claude-sshbuild hash:e9237552c61a71aae8c0e586311078ab3275a0f2(built 2026-04-22T00:07:35Z)- Linux remote: Ubuntu (kernel 6.17), systemd-logind,
KillUserProcesses=yes(upstream default),Linger=yes, reproduces on both Tailscale SSH and OpenSSH transports
Related issues (same family, not duplicates):
- #53606 —
claude remote-controlworkspace-trust prompt under systemd - #30447 — feature request for daemonizable
--headlessremote-control
User-visible symptom
Connection failed: RPC transport closed during capability handshake when connecting Claude desktop to a remote Linux workbench over SSH. Auto-reconnect retries 3× and gives up. Reproduces every connect attempt.
Diagnosis
Captured from ~/Library/Logs/Claude/ssh.log:
[BinaryDeployment] Remote server started
[BinaryDeployment] Bridge channel opened
[BinaryDeployment] Bridge stderr: claude-ssh: dial server:
dial unix /home/<user>/.claude/remote/rpc.sock:
connect: no such file or directory
[RemoteRPCClient] Stream closed
[RemoteServerController] server.capabilities handshake failed,
assuming legacy server: Connection closed
[RemoteServerController] Connection failed: RPC transport closed during capability handshake
Captured server-side at ~/.claude/remote/remote-server.log:
Claude remote server listening on /home/<user>/.claude/remote/rpc.sock
[Server] New connection from: @
[Server] Connection closed: @
Exact invocations the desktop sends over SSH (from system journal):
'~/.claude/remote/server' '--stop' '--socket' '~/.claude/remote/rpc.sock' \
&& '~/.claude/remote/server' '--serve' '--socket' '~/.claude/remote/rpc.sock' '--token-file' '~/.claude/remote/token.<hex>'
'~/.claude/remote/server' '--bridge' '--socket' '~/.claude/remote/rpc.sock'
Root cause
claude-ssh --serve uses traditional Unix daemonization (fork → setsid → fork → close fds). This escapes the controlling terminal but not the systemd session cgroup the SSH server placed it in.
With KillUserProcesses=yes (the upstream logind.conf default — see man logind.conf), when the SSH session that ran --serve exits, systemd-logind tears down the session scope and SIGKILLs every process in the cgroup, including the daemonized RPC server. The next SSH session running --bridge then dials a vanished/orphaned socket — hence the alternating no such file or directory and connection refused errors.
Linger=yes does not help — it keeps user@.service alive but does not exempt session-scoped processes from KillUserProcesses.
setsid -f does not help — it changes the controlling terminal but not the cgroup.
Reproducer (no Claude.app required)
On a stock Ubuntu host with the default KillUserProcesses=yes:
# Terminal A
ssh workbench '~/.claude/remote/server --serve \
--socket /tmp/v.sock --token-file <(echo t)'
# Returns 0 immediately, session ends.
sleep 2
# Terminal B (or new SSH session)
ssh workbench '~/.claude/remote/server --bridge --socket /tmp/v.sock < /dev/null'
# → claude-ssh: dial server: dial unix /tmp/v.sock: connect: connection refused
Workaround verified to fix it
sudo sed -i 's/^#\?KillUserProcesses=.*/KillUserProcesses=no/' /etc/systemd/logind.conf
sudo systemctl restart systemd-logind
After this, --serve daemons survive session end on any SSH transport (Tailscale SSH or OpenSSH), and Claude.app's workbench connection succeeds on its first attempt. Verified end-to-end: --bridge from a fresh session hits the listener, full handshake completes.
Suggested fixes (any one would resolve it for everyone)
- Run the daemon under
user@.serviceinstead of the session scope. On Linux, when--serveis invoked, exec viasystemd-run --user --scope --quiet -- <self> --serve …(or--unit=…/--collect) so the listener lives inuser@1000.serviceand is unaffected by session teardown. Fall back to current behavior whensystemd-runis absent or--userbus is unavailable. - Make
--serveforeground-friendly and have the parent SSH session keep it alive. I.e., move the lifetime to the bridge, not the daemon: first--bridgeconnect spawns the server inline if the socket is missing, sharing the bridge's session scope. Subsequent bridges re-use it. (This is what VS Code Remote-SSH does.) - Document the requirement. If neither of the above is acceptable, the workbench setup docs should call out
KillUserProcesses=no(orKillExcludeUsers=<user>) as a prerequisite. Right now the failure mode is a generic "RPC transport closed" with no actionable guidance.
(1) is the right fix — it's a one-call change at the daemonization site, doesn't require user config, and matches how every other modern Linux daemon-ish thing handles this.
Files / artifacts
~/Library/Logs/Claude/ssh.log— full handshake failure log~/Library/Logs/Claude/main.log— Claude desktop session log around the failures~/.claude/remote/remote-server.log(on workbench) — server-side log showing the lone@connect-then-close pattern- system journal on workbench:
journalctl -t tailscaled --since "10 min ago"shows the exact SSH command lines, useful for future repro
Happy to share any of these or run additional diagnostics.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗