mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add coder_workspace_ls MCP tool (#19652)
This commit is contained in:
+66
-75
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user