fix(ssh): use /bin/bash full path to bypass zsh profile sourcing

SSH passes commands to the remote's login shell (zsh on this system)
which parses them. Using just 'bash' still causes zsh to source its
profile files while resolving the command path, triggering the
"zsh:35: parse error" from .zshrc.

Using /bin/bash directly bypasses this - zsh executes the absolute
path without sourcing any profile files first.
This commit is contained in:
Pedram Amini
2026-01-31 21:04:21 -05:00
parent 8d4a8a383a
commit 2d32adf0ac
2 changed files with 10 additions and 6 deletions

View File

@@ -320,7 +320,7 @@ describe('ssh-command-builder', () => {
const lastArg = result.args[result.args.length - 1];
// Command is wrapped in bash with PATH setup (no profile sourcing)
expect(lastArg).toContain('bash --norc --noprofile -c');
expect(lastArg).toContain('/bin/bash --norc --noprofile -c');
expect(lastArg).toContain('export PATH=');
expect(lastArg).toContain('claude');
expect(lastArg).toContain('--print');
@@ -381,9 +381,9 @@ describe('ssh-command-builder', () => {
});
const wrappedCommand = result.args[result.args.length - 1];
// The command is wrapped in bash --norc --noprofile -c "..." with PATH setup
// The command is wrapped in /bin/bash --norc --noprofile -c "..." with PATH setup
// $ signs are escaped as \$ to prevent expansion by SSH's outer shell
expect(wrappedCommand).toContain('bash --norc --noprofile -c');
expect(wrappedCommand).toContain('/bin/bash --norc --noprofile -c');
expect(wrappedCommand).toContain('git');
expect(wrappedCommand).toContain('commit');
expect(wrappedCommand).toContain('fix:');
@@ -637,7 +637,7 @@ describe('ssh-command-builder', () => {
});
const wrappedCommand = result.args[result.args.length - 1];
// The prompt is wrapped in bash --norc --noprofile -c "..." with double-quote escaping
// The prompt is wrapped in /bin/bash --norc --noprofile -c "..." with double-quote escaping
// $PATH in the PROMPT should be escaped as \$PATH to prevent expansion
// (Note: the PATH setup also contains $PATH but that's intentional)
expect(wrappedCommand).toContain("'\\\\$PATH variable");

View File

@@ -273,19 +273,23 @@ export async function buildSshCommand(
// Wrap the command with explicit PATH setup instead of sourcing profile files.
// Profile files often chain to zsh or contain syntax incompatible with -c embedding.
//
// CRITICAL: Use /bin/bash (full path) instead of just 'bash' because:
// SSH passes the command to the remote's login shell (often zsh) which parses it.
// If we use 'bash', zsh still sources its profile files while resolving the command.
// Using /bin/bash directly bypasses this - zsh just executes the path without sourcing.
//
// We prepend common binary locations to PATH:
// - ~/.local/bin: Claude Code, pip --user installs
// - ~/bin: User scripts
// - /usr/local/bin: Homebrew on Intel Mac, manual installs
// - /opt/homebrew/bin: Homebrew on Apple Silicon
// - ~/.cargo/bin: Rust tools
// - ~/.nvm/versions/node/*/bin: Node.js via nvm (glob doesn't work, but current version does)
//
// This approach avoids all profile sourcing issues while ensuring agent binaries are found.
const pathPrefix =
'export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$HOME/.cargo/bin:$PATH"';
const escapedCommand = shellEscapeForDoubleQuotes(remoteCommand);
const wrappedCommand = `bash --norc --noprofile -c "${pathPrefix} && ${escapedCommand}"`;
const wrappedCommand = `/bin/bash --norc --noprofile -c "${pathPrefix} && ${escapedCommand}"`;
args.push(wrappedCommand);
// Log the exact command being built - use info level so it appears in system logs