From 474efd9023f85b9df38d238dc1437f13445d3165 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 4 Aug 2026 16:39:29 -0500 Subject: [PATCH] fix(remote-ssh): use remote user's real shell, keep dead panes alive Remote shell-mode sessions hardcoded 'exec bash -l', ignoring the remote user's actual login shell. sshd sets $SHELL from the remote user's /etc/passwd entry, so 'exec $SHELL -i -l' launches their real shell (zsh, fish, etc.) with rc files sourced, same fix as the local shell-mode launch. Also set remain-on-exit on the remote tmux session. It was only ever set on the local socket, so if the remote command exited for any reason -- even something transient -- tmux destroyed the pane, window, and (being the only session) the whole remote server, tearing down the local ssh attach along with it and leaving no trace to diagnose. The local pane saw this as an instant clean exit, and reconnect's -A then created a fresh session, which could repeat as a flap loop with no evidence surviving between attempts. Co-Authored-By: Claude Sonnet 5 --- src/remote-hosts.ts | 6 +++++- src/tmux-manager.ts | 8 ++++++++ test/remote-hosts.test.ts | 2 +- test/remote-ssh-options.test.ts | 3 ++- test/tmux-manager.test.ts | 3 ++- 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 54722c7b..04adff16 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -60,7 +60,11 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]): export function defaultRemoteCommandForMode(mode: SessionMode): string { const commands: Record = { - shell: 'exec bash -l', + // $SHELL, not a hardcoded bash: sshd sets it from the remote user's + // /etc/passwd entry, so this launches their actual login shell (zsh, + // fish, etc.). -i -l so it sources rc files (~/.zshrc etc.), matching + // the local shell-mode launch. + shell: 'exec $SHELL -i -l', // Mirror the LOCAL claude default so the remote agent runs non-interactively // (no trust-folder/permission prompt that nothing on the remote answers). The // per-host `commands.claude` override stays the escape hatch. diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index acfecb3a..e1697525 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -873,6 +873,14 @@ export function buildRemoteLaunchCommand(options: { // shared remote tmux server's other sessions keep their own prefix/mouse. const tmuxInvocation = [ `tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`, + // Without this, tmux's default behavior destroys the pane -> window -> + // session (and, being the only session, the whole remote server) the + // instant paneCommand exits for ANY reason -- even something transient. + // That tears down the local ssh -t attach along with it (dead pane, + // status whatever ssh reported), and reconnect's `-A` then creates a + // fresh session with no trace of what actually happened. Set first, so + // it applies as early as possible after the pane starts. + `set -t ${remoteName} remain-on-exit on`, `set -t ${remoteName} status off`, `set -t ${remoteName} mouse off`, `set -t ${remoteName} prefix C-q`, diff --git a/test/remote-hosts.test.ts b/test/remote-hosts.test.ts index 863bcff2..7986d11e 100644 --- a/test/remote-hosts.test.ts +++ b/test/remote-hosts.test.ts @@ -55,7 +55,7 @@ describe('remote-hosts domain', () => { }); it('returns safe mode defaults and remote display values', () => { - expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l'); + expect(defaultRemoteCommandForMode('shell')).toBe('exec $SHELL -i -l'); expect(defaultRemoteCommandForMode('codex')).toBe('exec codex'); // Mirrors the local claude default so the remote agent runs non-interactively. expect(defaultRemoteCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions'); diff --git a/test/remote-ssh-options.test.ts b/test/remote-ssh-options.test.ts index 81dbeb44..978b1233 100644 --- a/test/remote-ssh-options.test.ts +++ b/test/remote-ssh-options.test.ts @@ -150,9 +150,10 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => { const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`; const path = sh('/home/ubuntu/work'); - const paneCommand = `cd ${path} && exec bash -l`; + const paneCommand = `cd ${path} && exec $SHELL -i -l`; const tmuxInvocation = [ `tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`, + `set -t ${remoteName} remain-on-exit on`, `set -t ${remoteName} status off`, `set -t ${remoteName} mouse off`, `set -t ${remoteName} prefix C-q`, diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 72545fe4..16675b7d 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -137,7 +137,8 @@ describe('TmuxManager (unit)', () => { sessionId: 'abc123def456', }); - expect(command).toContain('exec bash -l'); + expect(command).toContain('exec $SHELL -i -l'); + expect(command).toContain('remain-on-exit on'); }); it('defaults claude to a non-interactive launch (--dangerously-skip-permissions)', () => {