[BUG] Desktop SSH: quitting the app opens N simultaneous ssh connections (N = sessions on the host, archived included) instead of using the existing control socket

Status Open
Maintainer reply None cached
Activity 0 comments · opened Sep 2, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

When Claude Desktop (macOS) quits while it has SSH sessions in the Code tab, it spawns one independent /usr/bin/ssh process per session transcript on the remote host (including archived sessions) to read the .jsonl files (wc -c / head -c 65536, tail -c +N, ls .../subagents/agent-*.jsonl). These invocations do not use the control socket the app already maintains for that host (-S .../ccd-ssh-XXXX/ctl), so each one is a full TCP connection with its own SSH handshake and authentication.

With ~30 sessions on the host this produces ~30 SSH connections in roughly 300 ms. My university's perimeter firewall treats this as an SSH brute-force attempt and blocks my IP, so the next launch of Claude Desktop fails to connect at all.

For comparison, app startup is well behaved: one ControlMaster=yes master per SSH connection profile (2 in my case), and the sftp / server --bridge channels reuse it via -S. Only the transcript reads on quit bypass the multiplexing.

Note: these transcript-read ssh invocations are not passed -F /dev/null and do not override ControlMaster/ControlPath, so they honour the user's ~/.ssh/config. If a user-owned ControlMaster socket for the host already exists at that moment, they multiplex through it; if it doesn't, all ~30 race to become master and fall back to independent connections. The masters spawned by the app itself pass -o ControlMaster=yes -S <private socket> (and, on the current version, -o ProxyCommand=none), so user config cannot route the app's own connections.

What Should Happen?

  • Transcript reads (and any other per-session remote command) should go through the existing per-host control socket (-S .../ccd-ssh-XXXX/ctl -o ControlMaster=no), exactly like the sftp and server --bridge channels already do.
  • Alternatively / additionally, the number of concurrent SSH connections per host should be bounded (e.g. batch the reads through a single remote command, or cap concurrency).
  • Archived sessions should probably not be touched at all on quit.

Error Messages/Logs

### Outbound SYNs to the remote host (port 2222) captured on the client during app quit


sudo tcpdump -ni any 'tcp dst port 2222 and tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack == 0'
# App launch: 4 SYNs over ~5 s (expected: 2 masters + channels)
17:04:25.272098 IP CLIENT.55372 > REMOTE.2222: Flags [SEW] ...
17:04:25.474030 IP CLIENT.55383 > REMOTE.2222: Flags [SEW] ...
17:04:28.254888 IP CLIENT.55423 > REMOTE.2222: Flags [SEW] ...
17:04:30.741843 IP CLIENT.55426 > REMOTE.2222: Flags [SEW] ...

# App quit (Cmd+Q): 30 SYNs between 17:04:39.631 and 17:04:39.933
17:04:39.631790 IP CLIENT.55432 > REMOTE.2222: Flags [SEW] ...
17:04:39.631790 IP CLIENT.55431 > REMOTE.2222: Flags [SEW] ...
17:04:39.634644 IP CLIENT.55433 > REMOTE.2222: Flags [SEW] ...
... (26 more, source ports 55434–55459) ...
17:04:39.933026 IP CLIENT.55460 > REMOTE.2222: Flags [SEW] ...


### ssh processes spawned by the app during quit

Captured with a 100 ms polling loop over `ps -ax -o pid,lstart,command | grep '[/]usr/bin/ssh'`. All of the following were spawned within the same second. Paths are representative; there were ~30 distinct `.jsonl` files across several project directories and worktrees, many belonging to archived sessions.


/usr/bin/ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -- HOSTALIAS sh -c 'LC_ALL=C sh -c '\''wc -c < "$1" && head -c 65536 "$1"'\'' sh '\''/home/USER/.claude/projects/-home-USER-PROJECT/<uuid>.jsonl'\'''
/usr/bin/ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -- HOSTALIAS sh -c 'LC_ALL=C tail -c +2681727 '\''/home/USER/.claude/projects/-home-USER-PROJECT/<uuid>.jsonl'\'''
/usr/bin/ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -- HOSTALIAS sh -c 'ls -1 '\''/home/USER/.claude/projects/-home-USER-PROJECT'\''/'\''<uuid>'\''/subagents/agent-*.jsonl '\''/home/USER/.claude/projects/-home-USER-PROJECT'\''/agent-*.jsonl 2>/dev/null'


Note the absence of `-S` / `-o ControlMaster=no` on these, compared to the channels the app opens at startup:


# per-profile master (one per SSH connection profile) — fine
/usr/bin/ssh -o Hostname=REMOTE -l USER -N -T -o ControlMaster=yes -o ControlPersist=no -p 2222 ... -S /var/folders/.../T/ccd-ssh-XXXX/ctl ... -- HOSTALIAS

# channels reusing the master — fine
/usr/bin/ssh -s -F /dev/null -T -o ControlMaster=no -S /var/folders/.../T/ccd-ssh-XXXX/ctl ... -- HOSTALIAS sftp
/usr/bin/ssh -F /dev/null -T -o ControlMaster=no -S /var/folders/.../T/ccd-ssh-XXXX/ctl ... -- HOSTALIAS '/home/USER/.claude/remote/srv/<hash>/server' '--bridge' '--socket' '...'

Steps to Reproduce

  1. On macOS, add an SSH connection in the Code tab to a Linux host and create ~20–30 sessions against it over time (some archived, some active).
  2. Start sudo tcpdump -ni any 'tcp dst port <ssh-port> and tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack == 0' on the Mac.
  3. Launch Claude Desktop; observe a handful of SYNs.
  4. Quit Claude Desktop with Cmd+Q; observe ~N SYNs (N ≈ number of session transcripts on the host) within a few hundred milliseconds.
  5. Optionally, on the remote host, journalctl -u ssh shows the corresponding burst of Accepted publickey lines.

Verified with tcpdump: app launch produces 1–3 SYNs, switching to the Code tab / opening sessions / sending messages / 5 min idle produce 0, and each Cmd+Q produces 27–44 SYNs within ~300 ms.

Claude Model

Not sure / Multiple models

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

Claude Desktop 1.44121.0 (a670de) 2026-09-01T00:22:02.000Z Remote ccd-cli: 2.1.255 (installed by Desktop on the remote host)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

iTerm2

Additional Information

Impact

  • Any host behind a rate-limiting firewall, fail2ban-style tooling, or sshd MaxStartups will throttle or ban the client IP.
  • In my case the university firewall bans the IP after the quit burst, so the next launch can't connect, which looks like a startup problem but is actually caused by the previous shutdown.

Workaround

Pre-establishing a user-owned ControlMaster for the host before launching the app (so the transcript reads multiplex through it):

Host HOSTALIAS
    ControlMaster auto
    ControlPath ~/.ssh/cm/%r@%h:%p
    ControlPersist yes

then ssh -Nf HOSTALIAS before opening Claude Desktop. This works only if the master already exists when the burst happens, and only for these transcript reads; the app's own masters ignore user config.

Related Issues

  • \#48530 — Desktop client floods server with SSH sessions during connection setup, triggers MaxStartups (closed; startup path now uses a control socket, but the quit-time transcript reads still bypass it)
  • \#54179 — repeated Accepted publickey every 5–10 s with multiple sessions on the same host

View original on GitHub ↗