From 2d32adf0ac1ecfa01cec141228801ac6747890a8 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sat, 31 Jan 2026 21:04:21 -0500 Subject: [PATCH] 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. --- src/__tests__/main/utils/ssh-command-builder.test.ts | 8 ++++---- src/main/utils/ssh-command-builder.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) 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