Use remote user from devcontainer CLI and respect shell in passwd (#48230)

KyleBarton created

Closes #46252

Uses the `remoteUser` property returned from the devcontainer CLI so
that settings are respected. Additionally, checks for a default shell in
`passwd` if `$SHELL` is not set.

Release Notes:

- Fixed remote_user and shell inconsistencies from within dev containers

Change summary

crates/dev_container/src/devcontainer_api.rs     |   8 
crates/recent_projects/src/remote_connections.rs |   1 
crates/remote/src/transport/docker.rs            | 145 ++++++++++++-----
crates/settings_content/src/settings_content.rs  |   1 
crates/workspace/src/persistence.rs              |   7 
5 files changed, 112 insertions(+), 50 deletions(-)

Detailed changes

crates/dev_container/src/devcontainer_api.rs 🔗

@@ -19,7 +19,7 @@ use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate};
 struct DevContainerUp {
     _outcome: String,
     container_id: String,
-    _remote_user: String,
+    remote_user: String,
     remote_workspace_folder: String,
 }
 
@@ -156,6 +156,7 @@ pub async fn start_dev_container(
         Ok(DevContainerUp {
             container_id,
             remote_workspace_folder,
+            remote_user,
             ..
         }) => {
             let project_name = match devcontainer_read_configuration(
@@ -180,6 +181,7 @@ pub async fn start_dev_container(
                 name: project_name,
                 container_id: container_id,
                 use_podman,
+                remote_user,
             };
 
             Ok((connection, remote_workspace_folder))
@@ -563,7 +565,7 @@ mod tests {
             up.container_id,
             "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
         );
-        assert_eq!(up._remote_user, "vscode");
+        assert_eq!(up.remote_user, "vscode");
         assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
 
         let json_in_plaintext = r#"[2026-01-22T16:19:08.802Z] @devcontainers/cli 0.80.1. Node.js v22.21.1. darwin 24.6.0 arm64.
@@ -574,7 +576,7 @@ mod tests {
             up.container_id,
             "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
         );
-        assert_eq!(up._remote_user, "vscode");
+        assert_eq!(up.remote_user, "vscode");
         assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
     }
 }

crates/recent_projects/src/remote_connections.rs 🔗

@@ -90,6 +90,7 @@ impl From<Connection> for RemoteConnectionOptions {
             Connection::DevContainer(conn) => {
                 RemoteConnectionOptions::Docker(DockerConnectionOptions {
                     name: conn.name,
+                    remote_user: conn.remote_user,
                     container_id: conn.container_id,
                     upload_binary_over_docker_exec: false,
                     use_podman: conn.use_podman,

crates/remote/src/transport/docker.rs 🔗

@@ -33,6 +33,7 @@ use crate::{
 pub struct DockerConnectionOptions {
     pub name: String,
     pub container_id: String,
+    pub remote_user: String,
     pub upload_binary_over_docker_exec: bool,
     pub use_podman: bool,
 }
@@ -115,16 +116,39 @@ impl DockerExecConnection {
         {
             Ok(shell) => match shell.trim() {
                 "" => {
-                    log::error!("$SHELL is not set, falling back to {default_shell}");
-                    default_shell.to_owned()
+                    log::info!("$SHELL is not set, checking passwd for user");
+                }
+                shell => {
+                    return shell.to_owned();
+                }
+            },
+            Err(e) => {
+                log::error!("Failed to get $SHELL: {e}. Checking passwd for user");
+            }
+        }
+
+        match self
+            .run_docker_exec(
+                "sh",
+                None,
+                &Default::default(),
+                &["-c", "getent passwd \"$(id -un)\" | cut -d: -f7"],
+            )
+            .await
+        {
+            Ok(shell) => match shell.trim() {
+                "" => {
+                    log::info!("No shell found in passwd, falling back to {default_shell}");
+                }
+                shell => {
+                    return shell.to_owned();
                 }
-                shell => shell.to_owned(),
             },
             Err(e) => {
-                log::error!("Failed to get shell: {e}");
-                default_shell.to_owned()
+                log::info!("Error getting shell from passwd: {e}. Falling back to {default_shell}");
             }
         }
+        default_shell.to_owned()
     }
 
     async fn check_remote_platform(&self) -> Result<RemotePlatform> {
@@ -370,44 +394,76 @@ impl DockerExecConnection {
         Ok(())
     }
 
-    async fn upload_file(
-        &self,
-        src_path: &Path,
-        dest_path: &RelPath,
-        remote_dir_for_server: &str,
+    async fn upload_and_chown(
+        docker_cli: String,
+        connection_options: DockerConnectionOptions,
+        src_path: String,
+        dst_path: String,
     ) -> Result<()> {
-        log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
-
-        let src_path_display = src_path.display().to_string();
-        let dest_path_str = dest_path.display(self.path_style());
-
-        let mut command = util::command::new_smol_command(self.docker_cli());
+        let mut command = util::command::new_smol_command(&docker_cli);
         command.arg("cp");
         command.arg("-a");
-        command.arg(&src_path_display);
-        command.arg(format!(
-            "{}:{}/{}",
-            &self.connection_options.container_id, remote_dir_for_server, dest_path_str
-        ));
+        command.arg(&src_path);
+        command.arg(format!("{}:{}", connection_options.container_id, dst_path));
 
         let output = command.output().await?;
 
+        if !output.status.success() {
+            let stderr = String::from_utf8_lossy(&output.stderr);
+            log::debug!("failed to upload via docker cp {src_path} -> {dst_path}: {stderr}",);
+            anyhow::bail!(
+                "failed to upload via docker cp {} -> {}: {}",
+                src_path,
+                dst_path,
+                stderr,
+            );
+        }
+
+        let mut chown_command = util::command::new_smol_command(&docker_cli);
+        chown_command.arg("exec");
+        chown_command.arg(connection_options.container_id);
+        chown_command.arg("chown");
+        chown_command.arg(format!(
+            "{}:{}",
+            connection_options.remote_user, connection_options.remote_user,
+        ));
+        chown_command.arg(&dst_path);
+
+        let output = chown_command.output().await?;
+
         if output.status.success() {
             return Ok(());
         }
 
         let stderr = String::from_utf8_lossy(&output.stderr);
-        log::debug!(
-            "failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}",
-        );
+        log::debug!("failed to change ownership for via chown: {stderr}",);
         anyhow::bail!(
-            "failed to upload file via docker cp {} -> {}: {}",
-            src_path_display,
-            dest_path_str,
+            "failed to change ownership for zed_remote_server via chown: {}",
             stderr,
         );
     }
 
+    async fn upload_file(
+        &self,
+        src_path: &Path,
+        dest_path: &RelPath,
+        remote_dir_for_server: &str,
+    ) -> Result<()> {
+        log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
+
+        let src_path_display = src_path.display().to_string();
+        let dest_path_str = dest_path.display(self.path_style());
+        let full_server_path = format!("{}/{}", remote_dir_for_server, dest_path_str);
+
+        Self::upload_and_chown(
+            self.docker_cli().to_string(),
+            self.connection_options.clone(),
+            src_path_display,
+            full_server_path,
+        )
+        .await
+    }
+
     async fn run_docker_command(
         &self,
         subcommand: &str,
@@ -440,6 +496,9 @@ impl DockerExecConnection {
             None => vec![],
         };
 
+        args.push("-u".to_string());
+        args.push(self.connection_options.remote_user.clone());
+
         for (k, v) in env.iter() {
             args.push("-e".to_string());
             let env_declaration = format!("{}={}", k, v);
@@ -581,6 +640,8 @@ impl RemoteConnection for DockerExecConnection {
         }
 
         docker_args.extend([
+            "-u".to_string(),
+            self.connection_options.remote_user.to_string(),
             "-w".to_string(),
             self.remote_dir_for_server.clone(),
             "-i".to_string(),
@@ -641,24 +702,14 @@ impl RemoteConnection for DockerExecConnection {
         let dest_path_str = dest_path.to_string();
         let src_path_display = src_path.display().to_string();
 
-        let mut command = util::command::new_smol_command(self.docker_cli());
-        command.arg("cp");
-        command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user
-        command.arg(src_path_display);
-        command.arg(format!(
-            "{}:{}",
-            self.connection_options.container_id, dest_path_str
-        ));
-
-        cx.background_spawn(async move {
-            let output = command.output().await?;
+        let upload_task = Self::upload_and_chown(
+            self.docker_cli().to_string(),
+            self.connection_options.clone(),
+            src_path_display,
+            dest_path_str,
+        );
 
-            if output.status.success() {
-                Ok(())
-            } else {
-                Err(anyhow::anyhow!("Failed to upload directory"))
-            }
-        })
+        cx.background_spawn(upload_task)
     }
 
     async fn kill(&self) -> Result<()> {
@@ -706,7 +757,11 @@ impl RemoteConnection for DockerExecConnection {
             inner_program.push("-l".to_string());
         };
 
-        let mut docker_args = vec!["exec".to_string()];
+        let mut docker_args = vec![
+            "exec".to_string(),
+            "-u".to_string(),
+            self.connection_options.remote_user.clone(),
+        ];
 
         if let Some(parsed_working_dir) = parsed_working_dir {
             docker_args.push("-w".to_string());

crates/workspace/src/persistence.rs 🔗

@@ -1425,7 +1425,7 @@ impl WorkspaceDb {
         options: RemoteConnectionOptions,
     ) -> Result<RemoteConnectionId> {
         let kind;
-        let mut user = None;
+        let user: Option<String>;
         let mut host = None;
         let mut port = None;
         let mut distro = None;
@@ -1448,12 +1448,14 @@ impl WorkspaceDb {
                 kind = RemoteConnectionKind::Docker;
                 container_id = Some(options.container_id);
                 name = Some(options.name);
-                use_podman = Some(options.use_podman)
+                use_podman = Some(options.use_podman);
+                user = Some(options.remote_user);
             }
             #[cfg(any(test, feature = "test-support"))]
             RemoteConnectionOptions::Mock(options) => {
                 kind = RemoteConnectionKind::Ssh;
                 host = Some(format!("mock-{}", options.id));
+                user = Some(format!("mock-user-{}", options.id));
             }
         }
         Self::get_or_create_remote_connection_query(
@@ -1691,6 +1693,7 @@ impl WorkspaceDb {
                 Some(RemoteConnectionOptions::Docker(DockerConnectionOptions {
                     container_id: container_id?,
                     name: name?,
+                    remote_user: user?,
                     upload_binary_over_docker_exec: false,
                     use_podman: use_podman?,
                 }))