diff --git a/src/__tests__/main/utils/ssh-command-builder.test.ts b/src/__tests__/main/utils/ssh-command-builder.test.ts index 6e31c56b..4c3e3fa6 100644 --- a/src/__tests__/main/utils/ssh-command-builder.test.ts +++ b/src/__tests__/main/utils/ssh-command-builder.test.ts @@ -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"); diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index 4405557e..9e19a9ff 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -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