From 474efd9023f85b9df38d238dc1437f13445d3165 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 4 Aug 2026 16:39:29 -0500 Subject: [PATCH 1/2] 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)', () => { From e803186dfe86529ae1adfc4f3d953b654117a35f Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 4 Aug 2026 16:41:27 -0500 Subject: [PATCH 2/2] fix(remote-ssh): route claude/opencode/codex/gemini/antigravity through login shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remain-on-exit (previous commit) preserved dead remote panes instead of destroying them, which revealed the real failure: `exec claude`/`exec opencode` ran under ssh's non-interactive, non-login remote-command shell, which only sees sshd's minimal default PATH — not the ~/.zshrc PATH entries where these CLIs actually live (e.g. ~/.local/bin, ~/.opencode/bin). Wrap them in `$SHELL -i -l -c ''`, mirroring the fix shell mode already had. Co-Authored-By: Claude Sonnet 5 --- src/remote-hosts.ts | 19 ++++++++++++++----- src/tmux-manager.ts | 7 ++++--- test/antigravity-mode.test.ts | 4 +++- test/remote-hosts.test.ts | 7 +++++-- test/tmux-manager.test.ts | 8 +++++++- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 04adff16..7bcddb44 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -59,6 +59,15 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]): } export function defaultRemoteCommandForMode(mode: SessionMode): string { + // Agent CLIs (claude/opencode/codex/gemini/antigravity) are typically installed + // under per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by + // the remote user's interactive-login shell startup files (~/.zshrc etc.). ssh's + // remote-command execution is neither interactive nor login, so a bare `exec + // claude` sees only sshd's minimal default PATH and fails with "command not + // found" (exit 127) — confirmed via `tmux capture-pane` on the + // remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c`, the same + // fix already used for shell mode below, so PATH is fully resolved before the + // CLI name is looked up. const commands: Record = { // $SHELL, not a hardcoded bash: sshd sets it from the remote user's // /etc/passwd entry, so this launches their actual login shell (zsh, @@ -68,11 +77,11 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string { // 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. - claude: 'exec claude --dangerously-skip-permissions', - opencode: 'exec opencode', - codex: 'exec codex', - gemini: 'exec gemini', - antigravity: 'exec agy', + claude: `exec $SHELL -i -l -c ${shellescape('claude --dangerously-skip-permissions')}`, + opencode: `exec $SHELL -i -l -c ${shellescape('opencode')}`, + codex: `exec $SHELL -i -l -c ${shellescape('codex')}`, + gemini: `exec $SHELL -i -l -c ${shellescape('gemini')}`, + antigravity: `exec $SHELL -i -l -c ${shellescape('agy')}`, }; return commands[mode as RemoteCommandMode] || commands.shell; } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index e1697525..ce8dcb47 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -852,13 +852,14 @@ export function buildRemoteLaunchCommand(options: { // hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's // downgraded 'auto' actually reaches the remote agent (the default command otherwise // ignored claudeMode). A per-host `commands.claude` override stays authoritative - // (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted - // command is byte-identical to before. Non-claude modes are unchanged. + // (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as + // `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that + // only an interactive login shell resolves (see that function's comment). const override = remote.commands?.[mode]; const modeCommand = override ? override : mode === 'claude' - ? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}` + ? `exec $SHELL -i -l -c ${shellescape(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)}` : defaultRemoteCommandForMode(mode); const remoteName = remoteTmuxSessionName(sessionId); diff --git a/test/antigravity-mode.test.ts b/test/antigravity-mode.test.ts index 466e7ed8..43f288d3 100644 --- a/test/antigravity-mode.test.ts +++ b/test/antigravity-mode.test.ts @@ -118,6 +118,8 @@ describe('Antigravity mode gates', () => { it('has docker/remote default commands', () => { expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy'); - expect(defaultRemoteCommandForMode('antigravity')).toBe('exec agy'); + // Routed through an interactive login shell so per-user PATH entries resolve — + // same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). + expect(defaultRemoteCommandForMode('antigravity')).toBe("exec $SHELL -i -l -c 'agy'"); }); }); diff --git a/test/remote-hosts.test.ts b/test/remote-hosts.test.ts index 7986d11e..af50989d 100644 --- a/test/remote-hosts.test.ts +++ b/test/remote-hosts.test.ts @@ -56,9 +56,12 @@ describe('remote-hosts domain', () => { it('returns safe mode defaults and remote display values', () => { expect(defaultRemoteCommandForMode('shell')).toBe('exec $SHELL -i -l'); - expect(defaultRemoteCommandForMode('codex')).toBe('exec codex'); + // Routed through an interactive login shell so per-user PATH entries (e.g. + // ~/.local/bin, ~/.opencode/bin) resolve — a bare `exec codex` sees only + // sshd's minimal default PATH and fails with "command not found". + expect(defaultRemoteCommandForMode('codex')).toBe("exec $SHELL -i -l -c 'codex'"); // Mirrors the local claude default so the remote agent runs non-interactively. - expect(defaultRemoteCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions'); + expect(defaultRemoteCommandForMode('claude')).toBe("exec $SHELL -i -l -c 'claude --dangerously-skip-permissions'"); expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local'); expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe( 'aamer@box.local:/opt/work' diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 16675b7d..af135f78 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -147,7 +147,13 @@ describe('TmuxManager (unit)', () => { remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' }, sessionId: 'abc123def456', }); - expect(command).toContain('exec claude --dangerously-skip-permissions'); + // Routed through an interactive login shell so ~/.local/bin (where `claude` + // typically lives) is on PATH — ssh's remote-command execution is neither + // interactive nor login, so a bare `exec claude` fails with "command not found". + // The inner quoting is escaped twice over (once per shellescape() layer), so + // assert on the unescaped substrings rather than the literal quoted form. + expect(command).toContain('exec $SHELL -i -l -c'); + expect(command).toContain('claude --dangerously-skip-permissions'); }); });