diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 54722c7b..7bcddb44 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -59,16 +59,29 @@ 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: '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. - 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 67d21217..d1a8420c 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -860,13 +860,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); @@ -881,6 +882,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/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 863bcff2..af50989d 100644 --- a/test/remote-hosts.test.ts +++ b/test/remote-hosts.test.ts @@ -55,10 +55,13 @@ describe('remote-hosts domain', () => { }); it('returns safe mode defaults and remote display values', () => { - expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l'); - expect(defaultRemoteCommandForMode('codex')).toBe('exec codex'); + expect(defaultRemoteCommandForMode('shell')).toBe('exec $SHELL -i -l'); + // 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/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 737e3af1..5cb43f21 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -146,7 +146,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)', () => { @@ -155,7 +156,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'); }); });