fix(agent): unify working directory resolution (#26102)

agentssh's CommandEnv, sftpHandler, and agentproc each resolved the
session working directory on their own and had drifted: sftpHandler used
the configured directory without checking it exists and bypassed the
injected EnvInfoer, while the others stat-checked and fell back to home.
Home and shell lookups could also skip the EnvInfoer seam through the
exported usershell.HomeDir and Get.

Resolve through a single usershell.ResolveWorkingDirectory and confine
host home and shell lookups to usershell, so SSH sessions, the process
API, and tests can no longer diverge and the injected environment is
always honored. This also fixes SFTP landing in a configured directory
that no longer exists.

Refs coder/coder#26099
This commit is contained in:
Mathias Fredriksson
2026-06-08 14:24:32 +03:00
committed by GitHub
parent 1e5dd83a95
commit 3955df796e
11 changed files with 231 additions and 116 deletions
+22 -6
View File
@@ -4,13 +4,15 @@ import (
"os"
"os/user"
"github.com/spf13/afero"
"golang.org/x/xerrors"
)
// HomeDir returns the home directory of the current user, giving
// priority to the $HOME environment variable.
// Deprecated: use EnvInfoer.HomeDir() instead.
func HomeDir() (string, error) {
// homeDir returns the home directory of the current user, giving
// priority to the $HOME environment variable. It backs
// SystemEnvInfo.HomeDir. Callers outside this package resolve the home
// directory through an EnvInfoer so the injected environment is honored.
func homeDir() (string, error) {
// First we check the environment.
homedir, err := os.UserHomeDir()
if err == nil {
@@ -25,6 +27,20 @@ func HomeDir() (string, error) {
return u.HomeDir, nil
}
// ResolveWorkingDirectory returns dir when it is non-empty and an existing
// directory on fs. Otherwise it falls back to the home directory
// reported by ei. SSH sessions and the process API share this so their
// working directory resolution cannot drift, and the home fallback goes
// through the injected EnvInfoer rather than the host directly.
func ResolveWorkingDirectory(fs afero.Fs, ei EnvInfoer, dir string) (string, error) {
if dir != "" {
if info, err := fs.Stat(dir); err == nil && info.IsDir() {
return dir, nil
}
}
return ei.HomeDir()
}
// EnvInfoer encapsulates external information about the environment.
type EnvInfoer interface {
// User returns the current user.
@@ -64,11 +80,11 @@ func (SystemEnvInfo) Environ() []string {
}
func (SystemEnvInfo) HomeDir() (string, error) {
return HomeDir()
return homeDir()
}
func (SystemEnvInfo) Shell(username string) (string, error) {
return Get(username)
return get(username)
}
func (SystemEnvInfo) ModifyCommand(name string, args ...string) (string, []string) {
+4 -3
View File
@@ -9,9 +9,10 @@ import (
"golang.org/x/xerrors"
)
// Get returns the $SHELL environment variable.
// Deprecated: use SystemEnvInfo.UserShell instead.
func Get(username string) (string, error) {
// get resolves the user's shell via dscl, falling back to $SHELL. It
// backs SystemEnvInfo.Shell. Callers resolve the shell through an
// EnvInfoer.
func get(username string) (string, error) {
// This command will output "UserShell: /bin/zsh" if successful, we
// can ignore the error since we have fallback behavior.
if !filepath.IsLocal(username) {
+4 -3
View File
@@ -10,9 +10,10 @@ import (
"golang.org/x/xerrors"
)
// Get returns the /etc/passwd entry for the username provided.
// Deprecated: use SystemEnvInfo.UserShell instead.
func Get(username string) (string, error) {
// get resolves the user's shell from /etc/passwd, falling back to
// $SHELL. It backs SystemEnvInfo.Shell. Callers resolve the shell
// through an EnvInfoer.
func get(username string) (string, error) {
contents, err := os.ReadFile("/etc/passwd")
if err != nil {
return "", xerrors.Errorf("read /etc/passwd: %w", err)
+103 -5
View File
@@ -1,26 +1,32 @@
package usershell_test
import (
"os"
"os/user"
"path/filepath"
"runtime"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/agent/usershell"
)
//nolint:paralleltest,tparallel // This test sets an environment variable.
func TestGet(t *testing.T) {
func TestShell(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}
ei := usershell.SystemEnvInfo{}
t.Run("Fallback", func(t *testing.T) {
t.Setenv("SHELL", "/bin/sh")
t.Run("NonExistentUser", func(t *testing.T) {
shell, err := usershell.Get("notauser")
shell, err := ei.Shell("notauser")
require.NoError(t, err)
require.Equal(t, "/bin/sh", shell)
})
@@ -31,14 +37,14 @@ func TestGet(t *testing.T) {
t.Setenv("SHELL", "")
t.Run("NotFound", func(t *testing.T) {
_, err := usershell.Get("notauser")
_, err := ei.Shell("notauser")
require.Error(t, err)
})
t.Run("User", func(t *testing.T) {
u, err := user.Current()
require.NoError(t, err)
shell, err := usershell.Get(u.Username)
shell, err := ei.Shell(u.Username)
require.NoError(t, err)
require.NotEmpty(t, shell)
})
@@ -46,10 +52,102 @@ func TestGet(t *testing.T) {
t.Run("Remove GOTRACEBACK=none", func(t *testing.T) {
t.Setenv("GOTRACEBACK", "none")
ei := usershell.SystemEnvInfo{}
env := ei.Environ()
for _, e := range env {
require.NotEqual(t, "GOTRACEBACK=none", e)
}
})
}
// homeEnvInfo reports a fixed home directory and otherwise delegates to
// SystemEnvInfo, isolating ResolveWorkingDirectory tests from the host's real
// home directory.
type homeEnvInfo struct {
usershell.SystemEnvInfo
home string
}
func (e homeEnvInfo) HomeDir() (string, error) { return e.home, nil }
// errorEnvInfo reports an error from HomeDir to exercise the fallback
// error path.
type errorEnvInfo struct {
usershell.SystemEnvInfo
err error
}
func (e errorEnvInfo) HomeDir() (string, error) { return "", e.err }
func TestResolveWorkingDirectory(t *testing.T) {
t.Parallel()
const home = "/home/coder"
ei := homeEnvInfo{home: home}
t.Run("Exists", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll("/work", 0o700))
dir, err := usershell.ResolveWorkingDirectory(fs, ei, "/work")
require.NoError(t, err)
require.Equal(t, "/work", dir)
})
t.Run("Missing", func(t *testing.T) {
t.Parallel()
dir, err := usershell.ResolveWorkingDirectory(afero.NewMemMapFs(), ei, "/work")
require.NoError(t, err)
require.Equal(t, home, dir)
})
t.Run("Empty", func(t *testing.T) {
t.Parallel()
dir, err := usershell.ResolveWorkingDirectory(afero.NewMemMapFs(), ei, "")
require.NoError(t, err)
require.Equal(t, home, dir)
})
t.Run("NotADirectory", func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "/work", []byte("file"), 0o600))
dir, err := usershell.ResolveWorkingDirectory(fs, ei, "/work")
require.NoError(t, err)
require.Equal(t, home, dir)
})
t.Run("HomeDirError", func(t *testing.T) {
t.Parallel()
ei := errorEnvInfo{err: xerrors.New("no home")}
_, err := usershell.ResolveWorkingDirectory(afero.NewMemMapFs(), ei, "")
require.ErrorContains(t, err, "no home")
})
t.Run("Symlink", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on Windows")
}
// MemMapFs cannot model symlinks. Use the real filesystem to
// confirm Stat follows symlinks: a link to a directory is honored,
// a link to a non-directory falls back to home.
fs := afero.NewOsFs()
base := t.TempDir()
realDir := filepath.Join(base, "real")
require.NoError(t, os.Mkdir(realDir, 0o700))
linkToDir := filepath.Join(base, "link-dir")
require.NoError(t, os.Symlink(realDir, linkToDir))
dir, err := usershell.ResolveWorkingDirectory(fs, ei, linkToDir)
require.NoError(t, err)
require.Equal(t, linkToDir, dir, "symlink to a directory should be honored")
realFile := filepath.Join(base, "file")
require.NoError(t, os.WriteFile(realFile, []byte("x"), 0o600))
linkToFile := filepath.Join(base, "link-file")
require.NoError(t, os.Symlink(realFile, linkToFile))
dir, err = usershell.ResolveWorkingDirectory(fs, ei, linkToFile)
require.NoError(t, err)
require.Equal(t, home, dir, "symlink to a non-directory should fall back to home")
})
}
+4 -3
View File
@@ -2,9 +2,10 @@ package usershell
import "os/exec"
// Get returns the command prompt binary name.
// Deprecated: use SystemEnvInfo.UserShell instead.
func Get(username string) (string, error) {
// get resolves the Windows shell, preferring pwsh.exe, then
// powershell.exe, then cmd.exe. It backs SystemEnvInfo.Shell. Callers
// resolve the shell through an EnvInfoer.
func get(username string) (string, error) {
_, err := exec.LookPath("pwsh.exe")
if err == nil {
return "pwsh.exe", nil