Merge pull request #210 from timkjr/fix/remote-ssh-login-shell

fix(remote-ssh): route shell + agent CLIs through a real interactive login shell
This commit is contained in:
Codeman maintainer
2026-08-05 01:34:23 +02:00
6 changed files with 51 additions and 16 deletions
+19 -6
View File
@@ -59,16 +59,29 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
} }
export function defaultRemoteCommandForMode(mode: SessionMode): string { 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<RemoteCommandMode, string> = { const commands: Record<RemoteCommandMode, string> = {
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 // Mirror the LOCAL claude default so the remote agent runs non-interactively
// (no trust-folder/permission prompt that nothing on the remote answers). The // (no trust-folder/permission prompt that nothing on the remote answers). The
// per-host `commands.claude` override stays the escape hatch. // per-host `commands.claude` override stays the escape hatch.
claude: 'exec claude --dangerously-skip-permissions', claude: `exec $SHELL -i -l -c ${shellescape('claude --dangerously-skip-permissions')}`,
opencode: 'exec opencode', opencode: `exec $SHELL -i -l -c ${shellescape('opencode')}`,
codex: 'exec codex', codex: `exec $SHELL -i -l -c ${shellescape('codex')}`,
gemini: 'exec gemini', gemini: `exec $SHELL -i -l -c ${shellescape('gemini')}`,
antigravity: 'exec agy', antigravity: `exec $SHELL -i -l -c ${shellescape('agy')}`,
}; };
return commands[mode as RemoteCommandMode] || commands.shell; return commands[mode as RemoteCommandMode] || commands.shell;
} }
+12 -3
View File
@@ -860,13 +860,14 @@ export function buildRemoteLaunchCommand(options: {
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's // hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
// downgraded 'auto' actually reaches the remote agent (the default command otherwise // downgraded 'auto' actually reaches the remote agent (the default command otherwise
// ignored claudeMode). A per-host `commands.claude` override stays authoritative // ignored claudeMode). A per-host `commands.claude` override stays authoritative
// (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted // (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as
// command is byte-identical to before. Non-claude modes are unchanged. // `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 override = remote.commands?.[mode];
const modeCommand = override const modeCommand = override
? override ? override
: mode === 'claude' : mode === 'claude'
? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}` ? `exec $SHELL -i -l -c ${shellescape(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)}`
: defaultRemoteCommandForMode(mode); : defaultRemoteCommandForMode(mode);
const remoteName = remoteTmuxSessionName(sessionId); const remoteName = remoteTmuxSessionName(sessionId);
@@ -881,6 +882,14 @@ export function buildRemoteLaunchCommand(options: {
// shared remote tmux server's other sessions keep their own prefix/mouse. // shared remote tmux server's other sessions keep their own prefix/mouse.
const tmuxInvocation = [ const tmuxInvocation = [
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`, `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} status off`,
`set -t ${remoteName} mouse off`, `set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`, `set -t ${remoteName} prefix C-q`,
+3 -1
View File
@@ -118,6 +118,8 @@ describe('Antigravity mode gates', () => {
it('has docker/remote default commands', () => { it('has docker/remote default commands', () => {
expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy'); 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'");
}); });
}); });
+6 -3
View File
@@ -55,10 +55,13 @@ describe('remote-hosts domain', () => {
}); });
it('returns safe mode defaults and remote display values', () => { 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'); // 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. // 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(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( expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe(
'aamer@box.local:/opt/work' 'aamer@box.local:/opt/work'
+2 -1
View File
@@ -150,9 +150,10 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`; const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`;
const path = sh('/home/ubuntu/work'); const path = sh('/home/ubuntu/work');
const paneCommand = `cd ${path} && exec bash -l`; const paneCommand = `cd ${path} && exec $SHELL -i -l`;
const tmuxInvocation = [ const tmuxInvocation = [
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`, `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} status off`,
`set -t ${remoteName} mouse off`, `set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`, `set -t ${remoteName} prefix C-q`,
+9 -2
View File
@@ -146,7 +146,8 @@ describe('TmuxManager (unit)', () => {
sessionId: 'abc123def456', 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)', () => { 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' }, remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
sessionId: 'abc123def456', 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');
}); });
}); });