From 5eea2c21afacb88938ac13f18f705a4df4c4afd6 Mon Sep 17 00:00:00 2001 From: Anton Nekipelov <226657+anton-107@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:49:32 +0200 Subject: [PATCH] Use bash as the default shell for SSH connect sessions The default login shell on Databricks compute images is /bin/sh. This change ensures users get bash in two ways: 1. Client-side: when no remote command is specified, explicitly request /bin/bash -l with PTY allocation instead of relying on the default login shell from /etc/passwd. 2. Server-side: attempt to update /etc/passwd to set bash as the login shell before starting sshd, which also covers IDE connections (VS Code, Cursor) that manage their own SSH sessions. Co-authored-by: Isaac --- experimental/ssh/internal/client/client.go | 12 +++- experimental/ssh/internal/server/server.go | 2 + experimental/ssh/internal/server/sshd.go | 66 ++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/experimental/ssh/internal/client/client.go b/experimental/ssh/internal/client/client.go index cd6d73f51e..57d0203c56 100644 --- a/experimental/ssh/internal/client/client.go +++ b/experimental/ssh/internal/client/client.go @@ -580,8 +580,18 @@ func spawnSSHClient(ctx context.Context, userName, privateKeyPath string, server if opts.UserKnownHostsFile != "" { sshArgs = append(sshArgs, "-o", "UserKnownHostsFile="+opts.UserKnownHostsFile) } + // When no remote command is specified, explicitly start bash as a login shell. + // The default login shell on Databricks compute images is often /bin/sh. + // The -t flag forces PTY allocation, which is required when specifying a remote command. + if len(opts.AdditionalArgs) == 0 { + sshArgs = append(sshArgs, "-t") + } sshArgs = append(sshArgs, hostName) - sshArgs = append(sshArgs, opts.AdditionalArgs...) + if len(opts.AdditionalArgs) == 0 { + sshArgs = append(sshArgs, "/bin/bash", "-l") + } else { + sshArgs = append(sshArgs, opts.AdditionalArgs...) + } log.Debugf(ctx, "Launching SSH client: ssh %s", strings.Join(sshArgs, " ")) sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...) diff --git a/experimental/ssh/internal/server/server.go b/experimental/ssh/internal/server/server.go index 6cf137618d..c35ccaedc3 100644 --- a/experimental/ssh/internal/server/server.go +++ b/experimental/ssh/internal/server/server.go @@ -72,6 +72,8 @@ func Run(ctx context.Context, client *databricks.WorkspaceClient, opts ServerOpt return fmt.Errorf("failed to save metadata to the workspace: %w", err) } + ensureBashLoginShell(ctx) + sshdConfigPath, err := prepareSSHDConfig(ctx, client, opts) if err != nil { return fmt.Errorf("failed to setup SSH configuration: %w", err) diff --git a/experimental/ssh/internal/server/sshd.go b/experimental/ssh/internal/server/sshd.go index f12ee352e7..388322dfb4 100644 --- a/experimental/ssh/internal/server/sshd.go +++ b/experimental/ssh/internal/server/sshd.go @@ -1,12 +1,14 @@ package server import ( + "bufio" "context" "errors" "fmt" "io/fs" "os" "os/exec" + "os/user" "path" "path/filepath" "strings" @@ -17,6 +19,70 @@ import ( "github.com/databricks/databricks-sdk-go" ) +const bashPath = "/bin/bash" + +// ensureBashLoginShell attempts to set bash as the login shell for the current user +// by editing /etc/passwd directly. This ensures interactive SSH sessions use bash +// instead of sh without depending on external tools like usermod. +func ensureBashLoginShell(ctx context.Context) { + if _, err := os.Stat(bashPath); err != nil { + log.Warnf(ctx, "bash not found at %s, keeping default login shell", bashPath) + return + } + + currentUser, err := user.Current() + if err != nil { + log.Warnf(ctx, "Failed to get current user for shell setup: %v", err) + return + } + + err = setLoginShellInPasswd(currentUser.Username, bashPath) + if err != nil { + log.Warnf(ctx, "Failed to set bash as login shell for user %s: %v", currentUser.Username, err) + } else { + log.Infof(ctx, "Set login shell to %s for user %s", bashPath, currentUser.Username) + } +} + +// setLoginShellInPasswd updates the login shell for the given user in /etc/passwd. +// Each line in /etc/passwd has 7 colon-delimited fields; the last field is the login shell. +func setLoginShellInPasswd(username, shell string) error { + const passwdPath = "/etc/passwd" + + data, err := os.ReadFile(passwdPath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", passwdPath, err) + } + + prefix := username + ":" + var result []string + found := false + + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, prefix) { + fields := strings.Split(line, ":") + if len(fields) == 7 { + if fields[6] == shell { + // Already set to the desired shell. + return nil + } + fields[6] = shell + line = strings.Join(fields, ":") + found = true + } + } + result = append(result, line) + } + + if !found { + return fmt.Errorf("user %s not found in %s", username, passwdPath) + } + + return os.WriteFile(passwdPath, []byte(strings.Join(result, "\n")+"\n"), 0o644) +} + func prepareSSHDConfig(ctx context.Context, client *databricks.WorkspaceClient, opts ServerOptions) (string, error) { clientPublicKey, err := keys.GetSecret(ctx, client, opts.SecretScopeName, opts.AuthorizedKeySecretName) if err != nil {