windows: Fix ssh reporting wrong password even it's actually correct (#38263)

ๅผ ๅฐ็™ฝ and Piotr Osiewicz created

Closes #34393

Currently, weโ€™re using `zed.exe --askpass` kind of like an `nc`
substitute, it prints out the SSH password to stdout with something like
`println!("user-pwd")`. `ssh.exe` then reads the password from stdout so
it can establish the connection.

The problem is that in release builds we set `subsystem=windows` to
avoid Windows spawning a black console window by default. The side
effect is that `zed.exe` no longer has a stdout, so `ssh.exe` canโ€™t read
the password.

Through testing, I confirmed that neither allocating a new console for
`zed.exe` nor attaching it to the parent processโ€™s stdout resolves the
issue. As a result, this PR updates the implementation to use `cli.exe
--askpass` instead.

TODO:

- [ ] Check that the `cli` path is correct on macOS
- [ ] Check that the `cli` path is correct on Linux

Release Notes:

- N/A

---------

Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>

Change summary

Cargo.lock                               |  2 
crates/askpass/src/askpass.rs            | 21 ++++-----
crates/askpass/src/encrypted_password.rs |  4 
crates/cli/Cargo.toml                    |  1 
crates/cli/src/main.rs                   | 11 +++++
crates/util/src/util.rs                  | 57 ++++++++++++++++++++++++-
crates/zed/Cargo.toml                    |  1 
crates/zed/src/main.rs                   | 11 -----
8 files changed, 78 insertions(+), 30 deletions(-)

Detailed changes

Cargo.lock ๐Ÿ”—

@@ -3080,6 +3080,7 @@ name = "cli"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "askpass",
  "clap",
  "collections",
  "core-foundation 0.10.0",
@@ -20215,7 +20216,6 @@ dependencies = [
  "agent_ui",
  "anyhow",
  "ashpd 0.11.0",
- "askpass",
  "assets",
  "assistant_tools",
  "audio",

crates/askpass/src/askpass.rs ๐Ÿ”—

@@ -85,11 +85,8 @@ impl AskPassSession {
         let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME);
         let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>();
         let listener = UnixListener::bind(&askpass_socket).context("creating askpass socket")?;
-        #[cfg(not(target_os = "windows"))]
-        let zed_path = util::get_shell_safe_zed_path()?;
-        #[cfg(target_os = "windows")]
-        let zed_path = std::env::current_exe()
-            .context("finding current executable path for use in askpass")?;
+        let zed_cli_path =
+            util::get_shell_safe_zed_cli_path().context("getting zed-cli path for askpass")?;
 
         let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>();
         let mut kill_tx = Some(askpass_kill_master_tx);
@@ -137,7 +134,7 @@ impl AskPassSession {
         });
 
         // Create an askpass script that communicates back to this process.
-        let askpass_script = generate_askpass_script(&zed_path, &askpass_socket);
+        let askpass_script = generate_askpass_script(&zed_cli_path, &askpass_socket);
         fs::write(&askpass_script_path, askpass_script)
             .await
             .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?;
@@ -254,10 +251,10 @@ pub fn main(socket: &str) {
 
 #[inline]
 #[cfg(not(target_os = "windows"))]
-fn generate_askpass_script(zed_path: &str, askpass_socket: &std::path::Path) -> String {
+fn generate_askpass_script(zed_cli_path: &str, askpass_socket: &std::path::Path) -> String {
     format!(
-        "{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n",
-        zed_exe = zed_path,
+        "{shebang}\n{print_args} | {zed_cli} --askpass={askpass_socket} 2> /dev/null \n",
+        zed_cli = zed_cli_path,
         askpass_socket = askpass_socket.display(),
         print_args = "printf '%s\\0' \"$@\"",
         shebang = "#!/bin/sh",
@@ -266,13 +263,13 @@ fn generate_askpass_script(zed_path: &str, askpass_socket: &std::path::Path) ->
 
 #[inline]
 #[cfg(target_os = "windows")]
-fn generate_askpass_script(zed_path: &std::path::Path, askpass_socket: &std::path::Path) -> String {
+fn generate_askpass_script(zed_cli_path: &str, askpass_socket: &std::path::Path) -> String {
     format!(
         r#"
         $ErrorActionPreference = 'Stop';
-        ($args -join [char]0) | & "{zed_exe}" --askpass={askpass_socket} 2> $null
+        ($args -join [char]0) | & "{zed_cli}" --askpass={askpass_socket} 2> $null
         "#,
-        zed_exe = zed_path.display(),
+        zed_cli = zed_cli_path,
         askpass_socket = askpass_socket.display(),
     )
 }

crates/askpass/src/encrypted_password.rs ๐Ÿ”—

@@ -67,7 +67,7 @@ impl TryFrom<&str> for EncryptedPassword {
                 unsafe {
                     CryptProtectMemory(
                         value.as_mut_ptr() as _,
-                        len,
+                        padded_length,
                         CRYPTPROTECTMEMORY_SAME_PROCESS,
                     )?;
                 }
@@ -97,7 +97,7 @@ pub(crate) fn decrypt(mut password: EncryptedPassword) -> Result<String> {
             unsafe {
                 CryptUnprotectMemory(
                     password.0.as_mut_ptr() as _,
-                    password.1,
+                    password.0.len().try_into()?,
                     CRYPTPROTECTMEMORY_SAME_PROCESS,
                 )
                 .context("while decrypting a SSH password")?

crates/cli/Cargo.toml ๐Ÿ”—

@@ -22,6 +22,7 @@ default = []
 
 [dependencies]
 anyhow.workspace = true
+askpass.workspace = true
 clap.workspace = true
 collections.workspace = true
 ipc-channel = "0.19"

crates/cli/src/main.rs ๐Ÿ”—

@@ -116,6 +116,11 @@ struct Args {
     ))]
     #[arg(long)]
     uninstall: bool,
+
+    /// Used for SSH/Git password authentication, to remove the need for netcat as a dependency,
+    /// by having Zed act like netcat communicating over a Unix socket.
+    #[arg(long, hide = true)]
+    askpass: Option<String>,
 }
 
 fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
@@ -203,6 +208,12 @@ fn main() -> Result<()> {
     }
     let args = Args::parse();
 
+    // `zed --askpass` Makes zed operate in nc/netcat mode for use with askpass
+    if let Some(socket) = &args.askpass {
+        askpass::main(socket);
+        return Ok(());
+    }
+
     // Set custom data directory before any path operations
     let user_data_dir = args.user_data_dir.clone();
     if let Some(dir) = &user_data_dir {

crates/util/src/util.rs ๐Ÿ”—

@@ -14,7 +14,7 @@ pub mod size;
 pub mod test;
 pub mod time;
 
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use futures::Future;
 use itertools::Either;
 use regex::Regex;
@@ -290,8 +290,6 @@ fn load_shell_from_passwd() -> Result<()> {
 #[cfg(unix)]
 /// Returns a shell escaped path for the current zed executable
 pub fn get_shell_safe_zed_path() -> anyhow::Result<String> {
-    use anyhow::Context;
-
     let zed_path = std::env::current_exe()
         .context("Failed to determine current zed executable path.")?
         .to_string_lossy()
@@ -307,6 +305,59 @@ pub fn get_shell_safe_zed_path() -> anyhow::Result<String> {
     Ok(zed_path_escaped.to_string())
 }
 
+/// Returns a shell escaped path for the zed cli executable, this function
+/// should be called from the zed executable, not zed-cli.
+pub fn get_shell_safe_zed_cli_path() -> Result<String> {
+    let zed_path =
+        std::env::current_exe().context("Failed to determine current zed executable path.")?;
+    let parent = zed_path
+        .parent()
+        .context("Failed to determine parent directory of zed executable path.")?;
+
+    let possible_locations: &[&str] = if cfg!(target_os = "macos") {
+        // On macOS, the zed executable and zed-cli are inside the app bundle,
+        // so here ./cli is for both installed and development builds.
+        &["./cli"]
+    } else if cfg!(target_os = "windows") {
+        // bin/zed.exe is for installed builds, ./cli.exe is for development builds.
+        &["bin/zed.exe", "./cli.exe"]
+    } else if cfg!(target_os = "linux") || cfg!(target_os = "freebsd") {
+        // bin is the standard, ./cli is for the target directory in development builds.
+        &["../bin/zed", "./cli"]
+    } else {
+        anyhow::bail!("unsupported platform for determining zed-cli path");
+    };
+
+    let zed_cli_path = possible_locations
+        .iter()
+        .find_map(|p| {
+            parent
+                .join(p)
+                .canonicalize()
+                .ok()
+                .filter(|p| p != &zed_path)
+        })
+        .with_context(|| {
+            format!(
+                "could not find zed-cli from any of: {}",
+                possible_locations.join(", ")
+            )
+        })?
+        .to_string_lossy()
+        .to_string();
+
+    #[cfg(target_os = "windows")]
+    {
+        Ok(zed_cli_path)
+    }
+    #[cfg(not(target_os = "windows"))]
+    {
+        Ok(shlex::try_quote(&zed_cli_path)
+            .context("Failed to shell-escape Zed executable path.")?
+            .to_string())
+    }
+}
+
 #[cfg(unix)]
 pub async fn load_login_shell_environment() -> Result<()> {
     load_shell_from_passwd().log_err();

crates/zed/Cargo.toml ๐Ÿ”—

@@ -25,7 +25,6 @@ agent.workspace = true
 agent_settings.workspace = true
 agent_ui.workspace = true
 anyhow.workspace = true
-askpass.workspace = true
 assets.workspace = true
 assistant_tools.workspace = true
 audio.workspace = true

crates/zed/src/main.rs ๐Ÿ”—

@@ -176,12 +176,6 @@ pub fn main() {
         return;
     }
 
-    // `zed --askpass` Makes zed operate in nc/netcat mode for use with askpass
-    if let Some(socket) = &args.askpass {
-        askpass::main(socket);
-        return;
-    }
-
     // `zed --nc` Makes zed operate in nc/netcat mode for use with MCP
     if let Some(socket) = &args.nc {
         match nc::main(socket) {
@@ -1249,11 +1243,6 @@ struct Args {
     #[arg(long)]
     system_specs: bool,
 
-    /// Used for SSH/Git password authentication, to remove the need for netcat as a dependency,
-    /// by having Zed act like netcat communicating over a Unix socket.
-    #[arg(long, hide = true)]
-    askpass: Option<String>,
-
     /// Used for the MCP Server, to remove the need for netcat as a dependency,
     /// by having Zed act like netcat communicating over a Unix socket.
     #[arg(long, hide = true)]