feat: add coder_workspace_ls MCP tool (#19652)

This commit is contained in:
Asher
2025-09-12 15:57:15 -08:00
committed by GitHub
parent 30330abaea
commit be7aa58075
6 changed files with 316 additions and 113 deletions
+66 -75
View File
@@ -11,23 +11,39 @@ import (
"strings"
"github.com/shirou/gopsutil/v4/disk"
"github.com/spf13/afero"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
var WindowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:\\$`)
func (*agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
func (a *agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var query LSRequest
if !httpapi.Read(ctx, rw, r, &query) {
// An absolute path may be optionally provided, otherwise a path split into an
// array must be provided in the body (which can be relative).
query := r.URL.Query()
parser := httpapi.NewQueryParamParser()
path := parser.String(query, "", "path")
parser.ErrorExcessParams(query)
if len(parser.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return
}
resp, err := listFiles(query)
var req workspacesdk.LSRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
resp, err := listFiles(a.filesystem, path, req)
if err != nil {
status := http.StatusInternalServerError
switch {
@@ -46,58 +62,66 @@ func (*agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
func listFiles(query LSRequest) (LSResponse, error) {
var fullPath []string
switch query.Relativity {
case LSRelativityHome:
home, err := os.UserHomeDir()
if err != nil {
return LSResponse{}, xerrors.Errorf("failed to get user home directory: %w", err)
func listFiles(fs afero.Fs, path string, query workspacesdk.LSRequest) (workspacesdk.LSResponse, error) {
absolutePathString := path
if absolutePathString != "" {
if !filepath.IsAbs(path) {
return workspacesdk.LSResponse{}, xerrors.Errorf("path must be absolute: %q", path)
}
fullPath = []string{home}
case LSRelativityRoot:
if runtime.GOOS == "windows" {
if len(query.Path) == 0 {
return listDrives()
} else {
var fullPath []string
switch query.Relativity {
case workspacesdk.LSRelativityHome:
home, err := os.UserHomeDir()
if err != nil {
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to get user home directory: %w", err)
}
if !WindowsDriveRegex.MatchString(query.Path[0]) {
return LSResponse{}, xerrors.Errorf("invalid drive letter %q", query.Path[0])
fullPath = []string{home}
case workspacesdk.LSRelativityRoot:
if runtime.GOOS == "windows" {
if len(query.Path) == 0 {
return listDrives()
}
if !WindowsDriveRegex.MatchString(query.Path[0]) {
return workspacesdk.LSResponse{}, xerrors.Errorf("invalid drive letter %q", query.Path[0])
}
} else {
fullPath = []string{"/"}
}
} else {
fullPath = []string{"/"}
default:
return workspacesdk.LSResponse{}, xerrors.Errorf("unsupported relativity type %q", query.Relativity)
}
default:
return LSResponse{}, xerrors.Errorf("unsupported relativity type %q", query.Relativity)
}
fullPath = append(fullPath, query.Path...)
fullPathRelative := filepath.Join(fullPath...)
absolutePathString, err := filepath.Abs(fullPathRelative)
if err != nil {
return LSResponse{}, xerrors.Errorf("failed to get absolute path of %q: %w", fullPathRelative, err)
fullPath = append(fullPath, query.Path...)
fullPathRelative := filepath.Join(fullPath...)
var err error
absolutePathString, err = filepath.Abs(fullPathRelative)
if err != nil {
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to get absolute path of %q: %w", fullPathRelative, err)
}
}
// codeql[go/path-injection] - The intent is to allow the user to navigate to any directory in their workspace.
f, err := os.Open(absolutePathString)
f, err := fs.Open(absolutePathString)
if err != nil {
return LSResponse{}, xerrors.Errorf("failed to open directory %q: %w", absolutePathString, err)
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to open directory %q: %w", absolutePathString, err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return LSResponse{}, xerrors.Errorf("failed to stat directory %q: %w", absolutePathString, err)
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to stat directory %q: %w", absolutePathString, err)
}
if !stat.IsDir() {
return LSResponse{}, xerrors.Errorf("path %q is not a directory", absolutePathString)
return workspacesdk.LSResponse{}, xerrors.Errorf("path %q is not a directory", absolutePathString)
}
// `contents` may be partially populated even if the operation fails midway.
contents, _ := f.ReadDir(-1)
respContents := make([]LSFile, 0, len(contents))
contents, _ := f.Readdir(-1)
respContents := make([]workspacesdk.LSFile, 0, len(contents))
for _, file := range contents {
respContents = append(respContents, LSFile{
respContents = append(respContents, workspacesdk.LSFile{
Name: file.Name(),
AbsolutePathString: filepath.Join(absolutePathString, file.Name()),
IsDir: file.IsDir(),
@@ -105,7 +129,7 @@ func listFiles(query LSRequest) (LSResponse, error) {
}
// Sort alphabetically: directories then files
slices.SortFunc(respContents, func(a, b LSFile) int {
slices.SortFunc(respContents, func(a, b workspacesdk.LSFile) int {
if a.IsDir && !b.IsDir {
return -1
}
@@ -117,35 +141,35 @@ func listFiles(query LSRequest) (LSResponse, error) {
absolutePath := pathToArray(absolutePathString)
return LSResponse{
return workspacesdk.LSResponse{
AbsolutePath: absolutePath,
AbsolutePathString: absolutePathString,
Contents: respContents,
}, nil
}
func listDrives() (LSResponse, error) {
func listDrives() (workspacesdk.LSResponse, error) {
// disk.Partitions() will return partitions even if there was a failure to
// get one. Any errored partitions will not be returned.
partitionStats, err := disk.Partitions(true)
if err != nil && len(partitionStats) == 0 {
// Only return the error if there were no partitions returned.
return LSResponse{}, xerrors.Errorf("failed to get partitions: %w", err)
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to get partitions: %w", err)
}
contents := make([]LSFile, 0, len(partitionStats))
contents := make([]workspacesdk.LSFile, 0, len(partitionStats))
for _, a := range partitionStats {
// Drive letters on Windows have a trailing separator as part of their name.
// i.e. `os.Open("C:")` does not work, but `os.Open("C:\\")` does.
name := a.Mountpoint + string(os.PathSeparator)
contents = append(contents, LSFile{
contents = append(contents, workspacesdk.LSFile{
Name: name,
AbsolutePathString: name,
IsDir: true,
})
}
return LSResponse{
return workspacesdk.LSResponse{
AbsolutePath: []string{},
AbsolutePathString: "",
Contents: contents,
@@ -163,36 +187,3 @@ func pathToArray(path string) []string {
}
return out
}
type LSRequest struct {
// e.g. [], ["repos", "coder"],
Path []string `json:"path"`
// Whether the supplied path is relative to the user's home directory,
// or the root directory.
Relativity LSRelativity `json:"relativity"`
}
type LSResponse struct {
AbsolutePath []string `json:"absolute_path"`
// Returned so clients can display the full path to the user, and
// copy it to configure file sync
// e.g. Windows: "C:\\Users\\coder"
// Linux: "/home/coder"
AbsolutePathString string `json:"absolute_path_string"`
Contents []LSFile `json:"contents"`
}
type LSFile struct {
Name string `json:"name"`
// e.g. "C:\\Users\\coder\\hello.txt"
// "/home/coder/hello.txt"
AbsolutePathString string `json:"absolute_path_string"`
IsDir bool `json:"is_dir"`
}
type LSRelativity string
const (
LSRelativityRoot LSRelativity = "root"
LSRelativityHome LSRelativity = "home"
)
+76 -38
View File
@@ -6,67 +6,103 @@ import (
"runtime"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
type testFs struct {
afero.Fs
}
func newTestFs(base afero.Fs) *testFs {
return &testFs{
Fs: base,
}
}
func (*testFs) Open(name string) (afero.File, error) {
return nil, os.ErrPermission
}
func TestListFilesWithQueryParam(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
query := workspacesdk.LSRequest{}
_, err := listFiles(fs, "not-relative", query)
require.Error(t, err)
require.Contains(t, err.Error(), "must be absolute")
tmpDir := t.TempDir()
err = fs.MkdirAll(tmpDir, 0o755)
require.NoError(t, err)
res, err := listFiles(fs, tmpDir, query)
require.NoError(t, err)
require.Len(t, res.Contents, 0)
}
func TestListFilesNonExistentDirectory(t *testing.T) {
t.Parallel()
query := LSRequest{
fs := afero.NewMemMapFs()
query := workspacesdk.LSRequest{
Path: []string{"idontexist"},
Relativity: LSRelativityHome,
Relativity: workspacesdk.LSRelativityHome,
}
_, err := listFiles(query)
_, err := listFiles(fs, "", query)
require.ErrorIs(t, err, os.ErrNotExist)
}
func TestListFilesPermissionDenied(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("creating an unreadable-by-user directory is non-trivial on Windows")
}
fs := newTestFs(afero.NewMemMapFs())
home, err := os.UserHomeDir()
require.NoError(t, err)
tmpDir := t.TempDir()
reposDir := filepath.Join(tmpDir, "repos")
err = os.Mkdir(reposDir, 0o000)
err = fs.MkdirAll(reposDir, 0o000)
require.NoError(t, err)
rel, err := filepath.Rel(home, reposDir)
require.NoError(t, err)
query := LSRequest{
query := workspacesdk.LSRequest{
Path: pathToArray(rel),
Relativity: LSRelativityHome,
Relativity: workspacesdk.LSRelativityHome,
}
_, err = listFiles(query)
_, err = listFiles(fs, "", query)
require.ErrorIs(t, err, os.ErrPermission)
}
func TestListFilesNotADirectory(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
home, err := os.UserHomeDir()
require.NoError(t, err)
tmpDir := t.TempDir()
err = fs.MkdirAll(tmpDir, 0o755)
require.NoError(t, err)
filePath := filepath.Join(tmpDir, "file.txt")
err = os.WriteFile(filePath, []byte("content"), 0o600)
err = afero.WriteFile(fs, filePath, []byte("content"), 0o600)
require.NoError(t, err)
rel, err := filepath.Rel(home, filePath)
require.NoError(t, err)
query := LSRequest{
query := workspacesdk.LSRequest{
Path: pathToArray(rel),
Relativity: LSRelativityHome,
Relativity: workspacesdk.LSRelativityHome,
}
_, err = listFiles(query)
_, err = listFiles(fs, "", query)
require.ErrorContains(t, err, "is not a directory")
}
@@ -76,7 +112,7 @@ func TestListFilesSuccess(t *testing.T) {
tc := []struct {
name string
baseFunc func(t *testing.T) string
relativity LSRelativity
relativity workspacesdk.LSRelativity
}{
{
name: "home",
@@ -85,7 +121,7 @@ func TestListFilesSuccess(t *testing.T) {
require.NoError(t, err)
return home
},
relativity: LSRelativityHome,
relativity: workspacesdk.LSRelativityHome,
},
{
name: "root",
@@ -95,7 +131,7 @@ func TestListFilesSuccess(t *testing.T) {
}
return "/"
},
relativity: LSRelativityRoot,
relativity: workspacesdk.LSRelativityRoot,
},
}
@@ -104,19 +140,20 @@ func TestListFilesSuccess(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
base := tc.baseFunc(t)
tmpDir := t.TempDir()
reposDir := filepath.Join(tmpDir, "repos")
err := os.Mkdir(reposDir, 0o755)
err := fs.MkdirAll(reposDir, 0o755)
require.NoError(t, err)
downloadsDir := filepath.Join(tmpDir, "Downloads")
err = os.Mkdir(downloadsDir, 0o755)
err = fs.MkdirAll(downloadsDir, 0o755)
require.NoError(t, err)
textFile := filepath.Join(tmpDir, "file.txt")
err = os.WriteFile(textFile, []byte("content"), 0o600)
err = afero.WriteFile(fs, textFile, []byte("content"), 0o600)
require.NoError(t, err)
var queryComponents []string
@@ -129,16 +166,16 @@ func TestListFilesSuccess(t *testing.T) {
queryComponents = pathToArray(rel)
}
query := LSRequest{
query := workspacesdk.LSRequest{
Path: queryComponents,
Relativity: tc.relativity,
}
resp, err := listFiles(query)
resp, err := listFiles(fs, "", query)
require.NoError(t, err)
require.Equal(t, tmpDir, resp.AbsolutePathString)
// Output is sorted
require.Equal(t, []LSFile{
require.Equal(t, []workspacesdk.LSFile{
{
Name: "Downloads",
AbsolutePathString: downloadsDir,
@@ -166,43 +203,44 @@ func TestListFilesListDrives(t *testing.T) {
t.Skip("skipping test on non-Windows OS")
}
query := LSRequest{
fs := afero.NewOsFs()
query := workspacesdk.LSRequest{
Path: []string{},
Relativity: LSRelativityRoot,
Relativity: workspacesdk.LSRelativityRoot,
}
resp, err := listFiles(query)
resp, err := listFiles(fs, "", query)
require.NoError(t, err)
require.Contains(t, resp.Contents, LSFile{
require.Contains(t, resp.Contents, workspacesdk.LSFile{
Name: "C:\\",
AbsolutePathString: "C:\\",
IsDir: true,
})
query = LSRequest{
query = workspacesdk.LSRequest{
Path: []string{"C:\\"},
Relativity: LSRelativityRoot,
Relativity: workspacesdk.LSRelativityRoot,
}
resp, err = listFiles(query)
resp, err = listFiles(fs, "", query)
require.NoError(t, err)
query = LSRequest{
query = workspacesdk.LSRequest{
Path: resp.AbsolutePath,
Relativity: LSRelativityRoot,
Relativity: workspacesdk.LSRelativityRoot,
}
resp, err = listFiles(query)
resp, err = listFiles(fs, "", query)
require.NoError(t, err)
// System directory should always exist
require.Contains(t, resp.Contents, LSFile{
require.Contains(t, resp.Contents, workspacesdk.LSFile{
Name: "Windows",
AbsolutePathString: "C:\\Windows",
IsDir: true,
})
query = LSRequest{
query = workspacesdk.LSRequest{
// Network drives are not supported.
Path: []string{"\\sshfs\\work"},
Relativity: LSRelativityRoot,
Relativity: workspacesdk.LSRelativityRoot,
}
resp, err = listFiles(query)
resp, err = listFiles(fs, "", query)
require.ErrorContains(t, err, "drive")
}