[BUG] Desktop GUI SSH: opening multiple Claude Code sessions on the same remote host invalidates each other's auth token, all sessions hang

Status Fixed / completed
Reported on v2.1.119
Maintainer reply None cached
Activity 7 comments · opened Apr 28, 2026 · closed Aug 25, 2026

Summary

When using the macOS Claude Code Desktop app's GUI SSH feature to drive Claude Code on a remote Linux host, opening more than one session against the same remote host causes all sessions to hang indefinitely. With a single session everything works; the second session is what triggers the failure.

The root cause on the remote side is that each new Desktop session spawns a brand-new ~/.claude/remote/server --serve with a brand-new auth token, invalidating the previous --serve's token. Older sessions' bridges are then rejected with Unauthorized request and disconnected.

Environment

| Component | Version |
|---|---|
| Local: macOS Claude Code Desktop | 1.4758.0 (fb266c) 2026-04-24 (latest) |
| Remote: ccd-cli (bundled & pushed by Desktop client) | 2.1.119 |
| Remote: ~/.claude/remote/server | claude-ssh e9237552c61a71aae8c0e586311078ab3275a0f2 (built 2026-04-22) |
| Remote OS | Ubuntu 24.04.4 LTS, x86_64, kernel 6.8 |
| Connection | macOS Desktop → GUI SSH → single remote Linux host |

Steps to reproduce

  1. Launch macOS Claude Code Desktop.
  2. Connect to a remote Linux host via GUI SSH; start a Claude Code session against a project on that host. Confirm replies come back normally. ✅
  3. While the first session is still open, open a second session in the Desktop GUI against the same remote host (any project).
  4. Send a message in either session.

Expected: Both sessions remain functional and independent, or the second session reuses the existing remote bridge.

Actual: Neither session returns. The chat UI hangs with no error.

Root cause (remote-side evidence)

1. Each new Desktop session spawns a new --serve with a new token

Within ~10 minutes of repeated GUI-SSH activity, multiple --serve generations stacked up, each with a different --token-file:

PID    CMD
389774 .../server --serve --token-file token.<hashA>   (10:52)
390185 .../server --serve --token-file token.<hashB>   (10:53)
395604 .../server --serve --token-file token.<hashC>   (10:58)
400203 .../server --serve --token-file token.<hashD>   (11:02)
403026 .../server --serve --token-file token.<hashE>   (11:06)

Multiple --bridge processes existed concurrently, but only the most recent one held a valid token.

2. Unauthorized request in ~/.claude/remote/remote-server.log

[Server] New connection from: @
[Server] RPC request:  method=server.capabilities, id=1
[Server] RPC response sent: id=1, hasError=false        ← bridge A authenticated, healthy
[Server] RPC request:  method=server.ping, id=2
[Server] RPC response sent: id=2, hasError=false
[Server] New connection from: @                         ← bridge B (a second session) connects
[Server] RPC request:  method=server.ping, id=0         ← fresh client, RPC counter restarts at 0
[Server] Unauthorized request: method=server.ping, id=0 ← rejected: presents the previous --serve's token
[Server] Connection closed: @

Unauthorized can only be emitted when a bridge presents a token that the current --serve does not recognize — i.e. at least two clients started against two different --serve generations, which is exactly the multi-session scenario.

3. Downstream: SSH reconnect storm

Once older bridges are kicked, the Desktop client auto-reconnects, spawning yet another --serve, which invalidates the latest token again. journalctl -u ssh shows new Accepted publickey lines every 5–10 seconds for the same source until the user quits the Desktop app. This looks similar to #48530 but is a consequence of token churn here, not a single-session connection flood.

How I recovered (verified)

After hitting this hang, the following sequence reliably recovers the host:

  1. On the Mac: Cmd+Q to fully quit Claude Code Desktop (closing windows alone is not enough — make sure no menu-bar icon is left and no remote-driving process is still alive).
  2. On the remote host: kill the residual server processes and remove the stale socket / token files:

``bash
pkill -f 'claude/remote/server'
rm -f ~/.claude/remote/rpc.sock ~/.claude/remote/token.*
``

  1. On the Mac: relaunch Claude Code Desktop and open only one session against the remote host. Send a message — it returns normally.

After step 3, remote-server.log shows a clean, steady heartbeat with no Unauthorized:

[Server] RPC request:  method=server.ping, id=N   → hasError=false
[Server] RPC request:  method=server.ping, id=N+1 → hasError=false
...

ps shows exactly one --serve and one --bridge; journalctl -u ssh shows ~4 Accepted publickey lines per 2 minutes (normal keepalive), no reconnect storm. The single --serve instance keeps its token stable for the lifetime of the session.

Current confirmed working configuration

On the same versions listed above (Desktop 1.4758.0, ccd-cli 2.1.119, server build e923755…), the GUI-SSH feature is only usable with one session per remote host at a time. As long as the user opens exactly one session, it remains stable indefinitely — which strongly suggests the bug is specifically in the per-session bring-up logic on the remote side (spawning a fresh --serve instead of reusing the existing one), not in the steady-state path.

Concretely:

  • ✅ 1 Desktop session ↔ 1 remote host: works, indefinitely.
  • ❌ 2+ Desktop sessions ↔ same remote host: all hang.
  • ✅ Workaround for users needing multiple sessions: SSH into the host in a regular terminal and run claude there (terminal CLI does not exhibit token churn).

Why this is distinct from existing issues

  • #48530 — single-client SSH connection flood; remote-server.log is empty because --serve never starts. Here --serve starts (multiple times) and the log clearly shows Unauthorized.
  • #27658 — multi-session cross-contamination, but explicitly local Desktop only, no SSH.
  • #43252 — "shared connection" hang within a single session's Code/Chat tabs, not multiple sessions.
  • #24317 — OAuth refresh race, unrelated to the per-host --serve token.

Suggested fix direction

The Desktop GUI should either:

  1. Reuse a single --serve + bridge per remote host across all sessions (preferred — also eliminates the SSH reconnect storm), or
  2. When spawning a new --serve is unavoidable, atomically migrate existing bridges to the new token instead of leaving them with a stale one and disconnecting them with Unauthorized.

View original on GitHub ↗

6 Comments

hedburgaren · 4 months ago

Confirming this also breaks cross-machine concurrency, with a working server-side workaround

Same root cause hits a slightly different scenario: two physical machines running CCD against the same remote Linux host. Symptoms are identical (Unauthorized request, hangs, reconnect storms) but the user-facing failure is more aggressive - opening CCD on machine B silently kicks the active session on machine A.

Diagnosis on our side

Each CCD connect-sequence on the remote contains these steps (captured by wrapping authorized_keys with command="..." + logging):

pkill -f "$HOME/.claude/remote/server" || true
pkill -f "$HOME/.claude/remote/ccd-cli/" || true
rm -f '/home/$USER/.claude/remote/server'
# ...SFTP upload of fresh server binary...
'/home/$USER/.claude/remote/server' --install --cli-dir ... --cli-version X.Y.Z
'/home/$USER/.claude/remote/server' --stop  --socket .../rpc.sock
'/home/$USER/.claude/remote/server' --serve --socket .../rpc.sock --token-file token.<hash>
'/home/$USER/.claude/remote/server' --bridge --socket .../rpc.sock

So beyond the token-churn already described above, each client actively pkills every other client's daemon and ccd-cli processes and SFTP-overwrites the server binary before bringing up its own. With two machines on different CCD versions (we had 2.1.119 vs 2.1.142), they end up in a perpetual war.

Workaround: per-source isolation via authorized_keys wrapper

Until the client is fixed to either share a single --serve per host, or to use per-instance paths, we deployed a server-side wrapper that gives each source machine its own private ~/.claude/remote-<name>/ tree. Two SSH keys (one per machine), each with a command="..." prefix in authorized_keys:

command="/home/$USER/.claude/remote/ccd-isolate.sh desktop" ssh-ed25519 AAAA... desktop-key
command="/home/$USER/.claude/remote/ccd-isolate.sh laptop"  ssh-ed25519 AAAA... laptop-key

The wrapper (ccd-isolate.sh):

#!/bin/bash
set -u
BURK="${1:-unknown}"
DEFAULT_DIR="$HOME/.claude/remote"
BURK_DIR="$HOME/.claude/remote-${BURK}"
mkdir -p "$BURK_DIR" && chmod 700 "$BURK_DIR"

ORIG="${SSH_ORIGINAL_COMMAND:-}"

# Rewrite ~/.claude/remote/ -> ~/.claude/remote-<burk>/ in the command string.
# This makes argv[0] of spawned daemons/ccd-cli unique, so client's pkill -f
# only matches its own processes after the same rewrite.
REWRITTEN="${ORIG//\$HOME\/.claude\/remote\//\$HOME\/.claude\/remote-${BURK}\/}"
REWRITTEN="${REWRITTEN//${DEFAULT_DIR}\//${BURK_DIR}\/}"

# SFTP carries client-side absolute paths inside its protocol, so the shell
# rewrite can't reach them. For SFTP only, enter a user+mount namespace and
# bind-mount the per-burk dir over the default path.
if [[ "$ORIG" == *"sftp-server"* ]]; then
  exec unshare --user --mount --map-root-user -- bash -c "
    mount --bind '$BURK_DIR' '$DEFAULT_DIR' || exit 91
    exec /usr/lib/openssh/sftp-server
  "
fi

[ -z "$ORIG" ] && exec "$SHELL" -l
exec bash -c "$REWRITTEN"
Effects
  • Each machine gets its own --serve daemon, socket, token file, server binary version, and ccd-cli tree
  • pkill -f from one machine no longer matches the other's processes (path is now unique)
  • SFTP uploads land in the correct per-machine dir via the namespace bind mount
  • Plugin dirs remain shared at ~/.claude/remote/plugins/ (works in practice today; may need similar isolation if plugin formats diverge between CCD versions)
Tradeoffs
  • Ubuntu 24+ requires sysctl kernel.apparmor_restrict_unprivileged_userns=0 (or a named AppArmor profile) for the unshare --user --mount call to work. The restriction was added in 24.04 by default
  • Wrapper assumes the literal substring ~/.claude/remote/ won't appear in unrelated commands — true in practice for CCD traffic but fragile in principle
  • Single-machine multi-session is still broken (each session would still trample the others under the same key) — that case needs the same wrapper to also distinguish by a per-session ID, which CCD doesn't currently expose at the SSH layer

Posting this in case anyone else needs cross-machine concurrency before a proper client-side fix lands.

asimonov · 3 months ago

i think i experience this issue. or something very similar. was there a fix or workaround? i am on Claude 1.13576.4 (414f85). ubuntu24 in virtual machine on macos. claude code app from macos host.

rhomat27 · 3 months ago

New trigger: duplicate host registration causes token race on app launch, no session required

Same root cause as described in this issue — concurrent --serve generations rotating the token file out from under a live bridge — but hit via a distinct path that requires no user action beyond opening the app.

Environment

  • Host OS: macOS 15 (Darwin 25.5.0), always-on Mac
  • Desktop app version: 1.15962.0
  • Remote ccd-cli versions staged: 2.1.177, 2.1.181, 2.1.187 (latest pushed today)
  • Active remote CLI version: 2.1.181 (running); 2.1.187 staged
  • Physical hosts involved: 1 — same Mac registered twice

Trigger

The Desktop app stores SSH connection entries per address. This host was registered twice: once by LAN IP and once by a VPN hostname (Tailscale). Both entries resolve to the same physical machine.

Current Desktop versions auto-connect all registered SSH hosts on app launch to fetch the directory tree. That means opening the app — with no session started, no second user-initiated connection — dials both entries simultaneously, spawning two concurrent --serve generations on a single host. The second spawn rotates the token file, invalidating the bridge from the first.

This is a lower-friction path to the same race than the issue's original repro. The user doesn't need to manually open a second session; it happens invisibly on every app launch as long as the host has two entries.

Repro

  1. Register one SSH host twice in the Desktop app's connection list — different address strings that resolve to the same machine (e.g., LAN IP + VPN/Tailscale hostname, or IP + FQDN).
  2. Close and reopen the Desktop app.
  3. Observe: two --serve processes spawn with different --token-file hashes, no session started by the user.
  4. Attempt any session on either entry → intermittent Unauthorized on server.ping, Connection closed, Lost connection to remote server.

Evidence — failure state (observed during the failure window; server restarted as part of the fix)

Token race — two concurrent --serve, distinct token-file hashes:

<pid> ~T+0 server --serve --socket .../<sess>/rpc.sock --token-file .../token.<hash-A>
<pid> ~T+2min server --serve --socket .../<sess>/rpc.sock --token-file .../token.<hash-B>

Same socket path, different token-file names. The bridge from the first connection holds <hash-A>; the second launch rotates it to <hash-B>.

Unauthorized log line (from remote-server.log during failure):

Unauthorized request: method=server.ping

Two-inode lsof — two processes bound to rpc.sock on different inodes (orphan on the stale inode, live serve on the current one):

server <pid-A> <user> 3u unix 0x<inode-stale> ... .../rpc.sock
server <pid-B> <user> 3u unix 0x<inode-live> ... .../rpc.sock

Note: captured live during the failure window. The server was restarted as part of the fix and logs rotated, so current logs show the clean post-fix state only.

Evidence — post-fix state (live)

After deleting one of the two duplicate registrations, a single --serve is running with a stable token:

$ ps aux | grep 'remote/srv' | grep -v grep
<pid> server --serve --socket .../<sess>/rpc.sock --token-file .../token.<hash>
<pid> server --bridge --socket .../<sess>/rpc.sock

$ lsof .../<sess>/rpc.sock
server <pid> <user> 3u unix 0x<inode> 0t0 .../rpc.sock
server <pid> <user> 7u unix 0x<inode> 0t0 .../rpc.sock

Single process, two FDs on the socket (normal for a Unix-socket server) — no orphan, no stale inode, no second generation.

Confirmed not a local watchdog

launchctl list and crontab -l show no cron or LaunchAgent respawning --serve. All respawns originated from the Desktop app's automatic host-connection on launch.

Fix

Delete one of the two duplicate registration entries in the Desktop app → single --serve on next launch → stable.

Suggested direction

Aligns with the existing suggestion #1 in this issue: deduplicate registered connections that resolve to the same host before auto-connecting on launch, or reuse a single --serve/bridge per physical host across connection entries regardless of the address string used to register it. The current behavior of launching a fresh --serve per connection entry — without checking whether the target is already serving — makes duplicate registrations silently destructive.

asimonov · 3 months ago

i had a session with Claude Cowork debbugging the issue on my machine...

the conclusion and suggested fix was as described - to leave just one entry in ssh_configs.json, instead of 3 aliases i accumulated for ubuntu24 VM running on same macos. unfortunately it is still not fixed - claude code GUI in Claude macos starts a session to ubuntu24 VM and then loses the connection in about 12 seconds.

i am on latest Claude 1.15962.1 (1e236d) 2026-06-26T19:44:44.000Z

is it possible to share claude cowork chat where i tried to debug the problem?

knoxy4 · 3 months ago

Still reproducible on current builds, and with a single session — deliberate multi-session isn't required. Any client-side reconnect (session drop mid-response, app relaunch) spawns a new --serve + token while the previous --serve persists (PPID 1) and keeps squatting rpc.sock, so the reconnect gets Unauthorized request: method=server.ping and the app enters a respawn loop.

Environment

| Component | Version |
|---|---|
| Remote: ccd-cli (pushed by Desktop client) | 2.1.197 |
| Remote: ~/.claude/remote/srv/<hash>/server | d20a77da22b7d4822f758654b226299ad7021c22 (built 2026-06-11) |
| Remote OS | Ubuntu 22.04 LTS, x86_64, kernel 5.15 |
| Connection | Desktop app → GUI SSH → Linux VPS |

Observed cycle (~2 min period, repeats indefinitely)

  1. App connects, spawns fresh --serve with new --token-file, launches ccd-cli session — works briefly (long enough to send a message and start streaming a response).
  2. Connection closes client-side mid-response.
  3. App reconnects with a new token; the old --serve (still alive, detached at PPID 1) rejects it:
[Server] New connection from: @
[Server] Unauthorized request: method=server.ping, id=0
[Server] Connection closed: @
  1. New --serve spawns anyway → generations pile up (observed 3 concurrent --serve on one rpc.sock, every one holding a --token-file path that no longer existed on disk).

The user-visible symptom is "connected for a second, then disconnected while it was thinking," followed by a permanent reconnecting state.

Additional data points

  • The app opens new SSH connections (sshd: user@notty) every ~15s during the loop without tearing down prior ones — 3-4 concurrent transport SSH sessions observed.
  • SSH transport itself is healthy (connections stay established); network is not a factor.
  • Killing all --serve/--bridge workers + wiping ~/.claude/remote/run/<id>/ gives one clean session, but the first client-side reconnect re-wedges it.
  • Current workaround: a watchdog loop on the remote that, whenever more than one --serve exists, kills all but the newest — this breaks the wedge at the cost of murdering the older session (acceptable single-user, matches the token semantics anyway since old tokens are already invalidated).

Suggested fix direction: --serve should exit when its token is superseded / its last channel dies, or the newly-spawned --serve should reap prior generations on the same socket before binding.

jcdas · 3 months ago

Confirming this bug is still present on the latest Claude Desktop (v1.18286.0) connecting to a remote Ubuntu host (deployed server claude-ssh 7c2f88d1…, app-cached CLI v2.1.197, remote claude CLI 2.1.116). Reproduced continuously, even after reducing to a single host profile.

Client log (~/Library/Logs/Claude/ssh.log) — the loop, verbatim:

[BinaryDeployment] Server binary up to date (…7c2f88d1…), skipping upload
[BinaryDeployment] Reattach failed: auth error
[BinaryDeployment] No reusable server; starting fresh
[RemoteServerController] server capabilities: version=…7c2f88d1…, methods=19, features=[process.stdin.offset]
[RemoteServerController] Connected to remote server (reused: false)
[RemoteRPCClient] Stream closed
[RemoteServerController] RPC stream closed
[RemoteServerController] Auto-reconnect attempt 1 …

In a single session this produced Reattach failed: auth error ×284 and RPC stream closed ×615. The capability handshake SUCCEEDS, then the RPC stream is closed ~1s later, and every reconnect fails to reattach (auth error), so it starts yet another fresh --serve.

Server side confirms the churn: multiple server --serve --socket …/rpc.sock --token-file …/token.<hex> processes racing on the SAME socket with DIFFERENT token files, and [Server] Unauthorized request: method=files.list / server.ping when an older server holds the socket while the client presents the newer token.

Ruled out (so this sits squarely in the app's reattach/token logic):

  • Network/SSH — USERAUTH_SUCCESS, connects fine
  • Remote shell startup noise — clean 18-byte stdout (login + non-login shells)
  • Server version / stale cache — wiped ~/.claude/remote/srv, re-deployed the same matching version, still fails
  • External process killers — none (verified with all custom cleanup disabled)
  • Multi-profile collision — reduced to a single host profile, still fails

Impact: the Desktop remote session is completely unusable for this host. Only the terminal CLI (ssh host; claude) works, because it bypasses BinaryDeployment entirely.

Suggested fix: make the reattach token stable across reconnects (persist/reuse the token the --serve was started with), and when reattach fails, terminate the previous --serve bound to that socket before starting a fresh one — instead of racing multiple servers on the same rpc socket.

Showing cached comments. Read the full discussion on GitHub ↗