Allow passing args to ssh (#19336)

Conrad Irwin created

This is useful for passing a custom identity file, jump hosts, etc.

Unlike with the v1 feature, we won't support `gh`/`gcloud` ssh wrappers
(yet?). I think the right way of supporting those would be to let
extensions provide remote projects.

Closes #19118

Release Notes:

- SSH remoting: restored ability to set arguments for SSH

Change summary

Cargo.lock                                    |   1 
crates/recent_projects/src/dev_servers.rs     | 122 +++++++++++---------
crates/recent_projects/src/recent_projects.rs |   4 
crates/recent_projects/src/ssh_connections.rs |  32 +++-
crates/remote/Cargo.toml                      |   1 
crates/remote/src/ssh_session.rs              |  85 ++++++++++++++
crates/workspace/src/persistence/model.rs     |  11 -
crates/zed/src/main.rs                        |  21 ++-
crates/zed/src/zed/open_listener.rs           |  28 +++-
9 files changed, 216 insertions(+), 89 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -9126,6 +9126,7 @@ dependencies = [
  "rpc",
  "serde",
  "serde_json",
+ "shlex",
  "smol",
  "tempfile",
  "thiserror",

crates/recent_projects/src/dev_servers.rs πŸ”—

@@ -13,19 +13,19 @@ use futures::channel::oneshot;
 use futures::future::Shared;
 use futures::FutureExt;
 use gpui::canvas;
-use gpui::pulsating_between;
 use gpui::AsyncWindowContext;
 use gpui::ClipboardItem;
 use gpui::Task;
 use gpui::WeakView;
 use gpui::{
-    Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
-    FocusableView, FontWeight, Model, PromptLevel, ScrollHandle, View, ViewContext,
+    AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, FontWeight,
+    Model, PromptLevel, ScrollHandle, View, ViewContext,
 };
 use picker::Picker;
 use project::terminals::wrap_for_ssh;
 use project::terminals::SshCommand;
 use project::Project;
+use remote::SshConnectionOptions;
 use rpc::proto::DevServerStatus;
 use settings::update_settings_file;
 use settings::Settings;
@@ -65,8 +65,9 @@ pub struct DevServerProjects {
 
 struct CreateDevServer {
     address_editor: View<Editor>,
-    creating: Option<Task<Option<()>>>,
+    address_error: Option<SharedString>,
     ssh_prompt: Option<View<SshPrompt>>,
+    _creating: Option<Task<Option<()>>>,
 }
 
 impl CreateDevServer {
@@ -77,8 +78,9 @@ impl CreateDevServer {
         });
         Self {
             address_editor,
-            creating: None,
+            address_error: None,
             ssh_prompt: None,
+            _creating: None,
         }
     }
 }
@@ -378,34 +380,22 @@ impl DevServerProjects {
     }
 
     fn create_ssh_server(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
-        let host = get_text(&editor, cx);
-        if host.is_empty() {
+        let input = get_text(&editor, cx);
+        if input.is_empty() {
             return;
         }
 
-        let mut host = host.trim_start_matches("ssh ");
-        let mut username: Option<String> = None;
-        let mut port: Option<u16> = None;
-
-        if let Some((u, rest)) = host.split_once('@') {
-            host = rest;
-            username = Some(u.to_string());
-        }
-        if let Some((rest, p)) = host.split_once(':') {
-            host = rest;
-            port = p.parse().ok()
-        }
-
-        if let Some((rest, p)) = host.split_once(" -p") {
-            host = rest;
-            port = p.trim().parse().ok()
-        }
-
-        let connection_options = remote::SshConnectionOptions {
-            host: host.to_string(),
-            username: username.clone(),
-            port,
-            password: None,
+        let connection_options = match SshConnectionOptions::parse_command_line(&input) {
+            Ok(c) => c,
+            Err(e) => {
+                self.mode = Mode::CreateDevServer(CreateDevServer {
+                    address_editor: editor,
+                    address_error: Some(format!("could not parse: {:?}", e).into()),
+                    ssh_prompt: None,
+                    _creating: None,
+                });
+                return;
+            }
         };
         let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
 
@@ -417,6 +407,7 @@ impl DevServerProjects {
         )
         .prompt_err("Failed to connect", cx, |_, _| None);
 
+        let address_editor = editor.clone();
         let creating = cx.spawn(move |this, mut cx| async move {
             match connection.await {
                 Some(_) => this
@@ -436,18 +427,31 @@ impl DevServerProjects {
                     .log_err(),
                 None => this
                     .update(&mut cx, |this, cx| {
-                        this.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
+                        address_editor.update(cx, |this, _| {
+                            this.set_read_only(false);
+                        });
+                        this.mode = Mode::CreateDevServer(CreateDevServer {
+                            address_editor,
+                            address_error: None,
+                            ssh_prompt: None,
+                            _creating: None,
+                        });
                         cx.notify()
                     })
                     .log_err(),
             };
             None
         });
-        let mut state = CreateDevServer::new(cx);
-        state.address_editor = editor;
-        state.ssh_prompt = Some(ssh_prompt.clone());
-        state.creating = Some(creating);
-        self.mode = Mode::CreateDevServer(state);
+
+        editor.update(cx, |this, _| {
+            this.set_read_only(true);
+        });
+        self.mode = Mode::CreateDevServer(CreateDevServer {
+            address_editor: editor,
+            address_error: None,
+            ssh_prompt: Some(ssh_prompt.clone()),
+            _creating: Some(creating),
+        });
     }
 
     fn view_server_options(
@@ -547,9 +551,6 @@ impl DevServerProjects {
                     return;
                 }
 
-                state.address_editor.update(cx, |this, _| {
-                    this.set_read_only(true);
-                });
                 self.create_ssh_server(state.address_editor.clone(), cx);
             }
             Mode::EditNickname(state) => {
@@ -812,6 +813,7 @@ impl DevServerProjects {
                     port: connection_options.port,
                     projects: vec![],
                     nickname: None,
+                    args: connection_options.args.unwrap_or_default(),
                 })
         });
     }
@@ -825,10 +827,7 @@ impl DevServerProjects {
 
         state.address_editor.update(cx, |editor, cx| {
             if editor.text(cx).is_empty() {
-                editor.set_placeholder_text(
-                    "Enter the command you use to SSH into this server: e.g., ssh me@my.server",
-                    cx,
-                );
+                editor.set_placeholder_text("ssh user@example -p 2222", cx);
             }
         });
 
@@ -854,27 +853,38 @@ impl DevServerProjects {
                     .map(|this| {
                         if let Some(ssh_prompt) = ssh_prompt {
                             this.child(h_flex().w_full().child(ssh_prompt))
+                        } else if let Some(address_error) = &state.address_error {
+                            this.child(
+                                h_flex().p_2().w_full().gap_2().child(
+                                    Label::new(address_error.clone())
+                                        .size(LabelSize::Small)
+                                        .color(Color::Error),
+                                ),
+                            )
                         } else {
-                            let color = Color::Muted.color(cx);
                             this.child(
                                 h_flex()
                                     .p_2()
                                     .w_full()
-                                    .items_center()
-                                    .justify_center()
-                                    .gap_2()
+                                    .gap_1()
                                     .child(
-                                        div().size_1p5().rounded_full().bg(color).with_animation(
-                                            "pulse-ssh-waiting-for-connection",
-                                            Animation::new(Duration::from_secs(2))
-                                                .repeat()
-                                                .with_easing(pulsating_between(0.2, 0.5)),
-                                            move |this, progress| this.bg(color.opacity(progress)),
-                                        ),
+                                        Label::new(
+                                            "Enter the command you use to SSH into this server.",
+                                        )
+                                        .color(Color::Muted)
+                                        .size(LabelSize::Small),
                                     )
                                     .child(
-                                        Label::new("Waiting for connection…")
-                                            .size(LabelSize::Small),
+                                        Button::new("learn-more", "Learn more…")
+                                            .label_size(LabelSize::Small)
+                                            .size(ButtonSize::None)
+                                            .color(Color::Accent)
+                                            .style(ButtonStyle::Transparent)
+                                            .on_click(|_, cx| {
+                                                cx.open_url(
+                                                    "https://zed.dev/docs/remote-development",
+                                                );
+                                            }),
                                     ),
                             )
                         }

crates/recent_projects/src/recent_projects.rs πŸ”—

@@ -21,7 +21,7 @@ use picker::{
 use rpc::proto::DevServerStatus;
 use serde::Deserialize;
 use settings::Settings;
-use ssh_connections::SshSettings;
+pub use ssh_connections::SshSettings;
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
@@ -384,11 +384,13 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 ..Default::default()
                             };
 
+                            let args = SshSettings::get_global(cx).args_for(&ssh_project.host, ssh_project.port, &ssh_project.user);
                             let connection_options = SshConnectionOptions {
                                 host: ssh_project.host.clone(),
                                 username: ssh_project.user.clone(),
                                 port: ssh_project.port,
                                 password: None,
+                                args,
                             };
 
                             let paths = ssh_project.paths.iter().map(PathBuf::from).collect();

crates/recent_projects/src/ssh_connections.rs πŸ”—

@@ -32,6 +32,23 @@ impl SshSettings {
     pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
         self.ssh_connections.clone().into_iter().flatten()
     }
+
+    pub fn args_for(
+        &self,
+        host: &str,
+        port: Option<u16>,
+        user: &Option<String>,
+    ) -> Option<Vec<String>> {
+        self.ssh_connections()
+            .filter_map(|conn| {
+                if conn.host == host && &conn.username == user && conn.port == port {
+                    Some(conn.args)
+                } else {
+                    None
+                }
+            })
+            .next()
+    }
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
@@ -45,6 +62,9 @@ pub struct SshConnection {
     /// Name to use for this server in UI.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub nickname: Option<SharedString>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    #[serde(default)]
+    pub args: Vec<String>,
 }
 impl From<SshConnection> for SshConnectionOptions {
     fn from(val: SshConnection) -> Self {
@@ -53,6 +73,7 @@ impl From<SshConnection> for SshConnectionOptions {
             username: val.username,
             port: val.port,
             password: None,
+            args: Some(val.args),
         }
     }
 }
@@ -151,11 +172,9 @@ impl Render for SshPrompt {
         v_flex()
             .key_context("PasswordPrompt")
             .size_full()
-            .justify_center()
             .child(
                 h_flex()
                     .p_2()
-                    .justify_center()
                     .flex_wrap()
                     .child(if self.error_message.is_some() {
                         Icon::new(IconName::XCircle)
@@ -174,24 +193,19 @@ impl Render for SshPrompt {
                             )
                             .into_any_element()
                     })
-                    .child(
-                        div()
-                            .ml_1()
-                            .child(Label::new("SSH Connection").size(LabelSize::Small)),
-                    )
                     .child(
                         div()
                             .text_ellipsis()
                             .overflow_x_hidden()
                             .when_some(self.error_message.as_ref(), |el, error| {
-                                el.child(Label::new(format!("-{}", error)).size(LabelSize::Small))
+                                el.child(Label::new(format!("{}", error)).size(LabelSize::Small))
                             })
                             .when(
                                 self.error_message.is_none() && self.status_message.is_some(),
                                 |el| {
                                     el.child(
                                         Label::new(format!(
-                                            "-{}",
+                                            "-{}…",
                                             self.status_message.clone().unwrap()
                                         ))
                                         .size(LabelSize::Small),

crates/remote/Cargo.toml πŸ”—

@@ -29,6 +29,7 @@ prost.workspace = true
 rpc = { workspace = true, features = ["gpui"] }
 serde.workspace = true
 serde_json.workspace = true
+shlex.workspace = true
 smol.workspace = true
 tempfile.workspace = true
 thiserror.workspace = true

crates/remote/src/ssh_session.rs πŸ”—

@@ -61,9 +61,89 @@ pub struct SshConnectionOptions {
     pub username: Option<String>,
     pub port: Option<u16>,
     pub password: Option<String>,
+    pub args: Option<Vec<String>>,
 }
 
 impl SshConnectionOptions {
+    pub fn parse_command_line(input: &str) -> Result<Self> {
+        let input = input.trim_start_matches("ssh ");
+        let mut hostname: Option<String> = None;
+        let mut username: Option<String> = None;
+        let mut port: Option<u16> = None;
+        let mut args = Vec::new();
+
+        // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
+        const ALLOWED_OPTS: &[&str] = &[
+            "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
+        ];
+        const ALLOWED_ARGS: &[&str] = &[
+            "-B", "-b", "-c", "-D", "-I", "-i", "-J", "-L", "-l", "-m", "-o", "-P", "-p", "-R",
+            "-w",
+        ];
+
+        let mut tokens = shlex::split(input)
+            .ok_or_else(|| anyhow!("invalid input"))?
+            .into_iter();
+
+        'outer: while let Some(arg) = tokens.next() {
+            if ALLOWED_OPTS.contains(&(&arg as &str)) {
+                args.push(arg.to_string());
+                continue;
+            }
+            if arg == "-p" {
+                port = tokens.next().and_then(|arg| arg.parse().ok());
+                continue;
+            } else if let Some(p) = arg.strip_prefix("-p") {
+                port = p.parse().ok();
+                continue;
+            }
+            if arg == "-l" {
+                username = tokens.next();
+                continue;
+            } else if let Some(l) = arg.strip_prefix("-l") {
+                username = Some(l.to_string());
+                continue;
+            }
+            for a in ALLOWED_ARGS {
+                if arg == *a {
+                    args.push(arg);
+                    if let Some(next) = tokens.next() {
+                        args.push(next);
+                    }
+                    continue 'outer;
+                } else if arg.starts_with(a) {
+                    args.push(arg);
+                    continue 'outer;
+                }
+            }
+            if arg.starts_with("-") || hostname.is_some() {
+                anyhow::bail!("unsupported argument: {:?}", arg);
+            }
+            let mut input = &arg as &str;
+            if let Some((u, rest)) = input.split_once('@') {
+                input = rest;
+                username = Some(u.to_string());
+            }
+            if let Some((rest, p)) = input.split_once(':') {
+                input = rest;
+                port = p.parse().ok()
+            }
+            hostname = Some(input.to_string())
+        }
+
+        let Some(hostname) = hostname else {
+            anyhow::bail!("missing hostname");
+        };
+
+        Ok(Self {
+            host: hostname.to_string(),
+            username: username.clone(),
+            port,
+            password: None,
+            args: Some(args),
+        })
+    }
+
     pub fn ssh_url(&self) -> String {
         let mut result = String::from("ssh://");
         if let Some(username) = &self.username {
@@ -78,6 +158,10 @@ impl SshConnectionOptions {
         result
     }
 
+    pub fn additional_args(&self) -> Option<&Vec<String>> {
+        self.args.as_ref()
+    }
+
     fn scp_url(&self) -> String {
         if let Some(username) = &self.username {
             format!("{}@{}", username, self.host)
@@ -1179,6 +1263,7 @@ impl SshRemoteConnection {
             .stderr(Stdio::piped())
             .env("SSH_ASKPASS_REQUIRE", "force")
             .env("SSH_ASKPASS", &askpass_script_path)
+            .args(connection_options.additional_args().unwrap_or(&Vec::new()))
             .args([
                 "-N",
                 "-o",

crates/workspace/src/persistence/model.rs πŸ”—

@@ -11,7 +11,7 @@ use db::sqlez::{
 };
 use gpui::{AsyncWindowContext, Model, View, WeakView};
 use project::Project;
-use remote::{ssh_session::SshProjectId, SshConnectionOptions};
+use remote::ssh_session::SshProjectId;
 use serde::{Deserialize, Serialize};
 use std::{
     path::{Path, PathBuf},
@@ -50,15 +50,6 @@ impl SerializedSshProject {
             })
             .collect()
     }
-
-    pub fn connection_options(&self) -> SshConnectionOptions {
-        SshConnectionOptions {
-            host: self.host.clone(),
-            username: self.user.clone(),
-            port: self.port,
-            password: None,
-        }
-    }
 }
 
 impl StaticColumnCount for SerializedSshProject {

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

@@ -33,7 +33,7 @@ use assets::Assets;
 use node_runtime::{NodeBinaryOptions, NodeRuntime};
 use parking_lot::Mutex;
 use project::project_settings::ProjectSettings;
-use recent_projects::open_ssh_project;
+use recent_projects::{open_ssh_project, SshSettings};
 use release_channel::{AppCommitSha, AppVersion};
 use session::{AppSession, Session};
 use settings::{
@@ -214,6 +214,7 @@ fn init_common(app_state: Arc<AppState>, cx: &mut AppContext) -> Arc<PromptBuild
         ThemeRegistry::global(cx),
         cx,
     );
+    recent_projects::init(cx);
     prompt_builder
 }
 
@@ -248,7 +249,6 @@ fn init_ui(
     audio::init(Assets, cx);
     workspace::init(app_state.clone(), cx);
 
-    recent_projects::init(cx);
     go_to_line::init(cx);
     file_finder::init(cx);
     tab_switcher::init(cx);
@@ -881,18 +881,25 @@ async fn restore_or_create_workspace(
                     })?;
                     task.await?;
                 }
-                SerializedWorkspaceLocation::Ssh(ssh_project) => {
+                SerializedWorkspaceLocation::Ssh(ssh) => {
+                    let args = cx
+                        .update(|cx| {
+                            SshSettings::get_global(cx).args_for(&ssh.host, ssh.port, &ssh.user)
+                        })
+                        .ok()
+                        .flatten();
                     let connection_options = SshConnectionOptions {
-                        host: ssh_project.host.clone(),
-                        username: ssh_project.user.clone(),
-                        port: ssh_project.port,
+                        args,
+                        host: ssh.host.clone(),
+                        username: ssh.user.clone(),
+                        port: ssh.port,
                         password: None,
                     };
                     let app_state = app_state.clone();
                     cx.spawn(move |mut cx| async move {
                         recent_projects::open_ssh_project(
                             connection_options,
-                            ssh_project.paths.into_iter().map(PathBuf::from).collect(),
+                            ssh.paths.into_iter().map(PathBuf::from).collect(),
                             app_state,
                             workspace::OpenOptions::default(),
                             &mut cx,

crates/zed/src/zed/open_listener.rs πŸ”—

@@ -16,8 +16,9 @@ use futures::future::join_all;
 use futures::{FutureExt, SinkExt, StreamExt};
 use gpui::{AppContext, AsyncAppContext, Global, WindowHandle};
 use language::{Bias, Point};
-use recent_projects::open_ssh_project;
+use recent_projects::{open_ssh_project, SshSettings};
 use remote::SshConnectionOptions;
+use settings::Settings;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::time::Duration;
@@ -48,7 +49,7 @@ impl OpenRequest {
             } else if let Some(file) = url.strip_prefix("zed://file") {
                 this.parse_file_path(file)
             } else if url.starts_with("ssh://") {
-                this.parse_ssh_file_path(&url)?
+                this.parse_ssh_file_path(&url, cx)?
             } else if let Some(request_path) = parse_zed_link(&url, cx) {
                 this.parse_request_path(request_path).log_err();
             } else {
@@ -65,7 +66,7 @@ impl OpenRequest {
         }
     }
 
-    fn parse_ssh_file_path(&mut self, file: &str) -> Result<()> {
+    fn parse_ssh_file_path(&mut self, file: &str, cx: &AppContext) -> Result<()> {
         let url = url::Url::parse(file)?;
         let host = url
             .host()
@@ -77,11 +78,13 @@ impl OpenRequest {
         if !self.open_paths.is_empty() {
             return Err(anyhow!("cannot open both local and ssh paths"));
         }
+        let args = SshSettings::get_global(cx).args_for(&host, port, &username);
         let connection = SshConnectionOptions {
             username,
             password,
             host,
             port,
+            args,
         };
         if let Some(ssh_connection) = &self.ssh_connection {
             if *ssh_connection != connection {
@@ -419,12 +422,25 @@ async fn open_workspaces(
                         errored = true
                     }
                 }
-                SerializedWorkspaceLocation::Ssh(ssh_project) => {
+                SerializedWorkspaceLocation::Ssh(ssh) => {
                     let app_state = app_state.clone();
+                    let args = cx
+                        .update(|cx| {
+                            SshSettings::get_global(cx).args_for(&ssh.host, ssh.port, &ssh.user)
+                        })
+                        .ok()
+                        .flatten();
+                    let connection_options = SshConnectionOptions {
+                        args,
+                        host: ssh.host.clone(),
+                        username: ssh.user.clone(),
+                        port: ssh.port,
+                        password: None,
+                    };
                     cx.spawn(|mut cx| async move {
                         open_ssh_project(
-                            ssh_project.connection_options(),
-                            ssh_project.paths.into_iter().map(PathBuf::from).collect(),
+                            connection_options,
+                            ssh.paths.into_iter().map(PathBuf::from).collect(),
                             app_state,
                             OpenOptions::default(),
                             &mut cx,