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")
}
+58
View File
@@ -42,6 +42,7 @@ const (
ToolNameWorkspaceBash = "coder_workspace_bash"
ToolNameChatGPTSearch = "search"
ToolNameChatGPTFetch = "fetch"
ToolNameWorkspaceLS = "coder_workspace_ls"
ToolNameWorkspaceReadFile = "coder_workspace_read_file"
ToolNameWorkspaceWriteFile = "coder_workspace_write_file"
ToolNameWorkspaceEditFile = "coder_workspace_edit_file"
@@ -213,6 +214,7 @@ var All = []GenericTool{
WorkspaceBash.Generic(),
ChatGPTSearch.Generic(),
ChatGPTFetch.Generic(),
WorkspaceLS.Generic(),
WorkspaceReadFile.Generic(),
WorkspaceWriteFile.Generic(),
WorkspaceEditFile.Generic(),
@@ -1373,6 +1375,62 @@ type MinimalTemplate struct {
ActiveUserCount int `json:"active_user_count"`
}
type WorkspaceLSArgs struct {
Workspace string `json:"workspace"`
Path string `json:"path"`
}
type WorkspaceLSFile struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
}
type WorkspaceLSResponse struct {
Contents []WorkspaceLSFile `json:"contents"`
}
var WorkspaceLS = Tool[WorkspaceLSArgs, WorkspaceLSResponse]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceLS,
Description: `List directories in a workspace.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.",
},
"path": map[string]any{
"type": "string",
"description": "The absolute path of the directory in the workspace to list.",
},
},
Required: []string{"path", "workspace"},
},
},
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceLSArgs) (WorkspaceLSResponse, error) {
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
if err != nil {
return WorkspaceLSResponse{}, err
}
defer conn.Close()
res, err := conn.LS(ctx, args.Path, workspacesdk.LSRequest{})
if err != nil {
return WorkspaceLSResponse{}, err
}
contents := make([]WorkspaceLSFile, len(res.Contents))
for i, f := range res.Contents {
contents[i] = WorkspaceLSFile{
Path: f.AbsolutePathString,
IsDir: f.IsDir,
}
}
return WorkspaceLSResponse{Contents: contents}, nil
},
}
type WorkspaceReadFileArgs struct {
Workspace string `json:"workspace"`
Path string `json:"path"`
+46
View File
@@ -454,6 +454,52 @@ func TestTools(t *testing.T) {
require.Equal(t, "owner format works", result.Output)
})
t.Run("WorkspaceLS", func(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForAgent(t)
fs := afero.NewMemMapFs()
_ = agenttest.New(t, client.URL, agentToken, func(opts *agent.Options) {
opts.Filesystem = fs
})
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
tb, err := toolsdk.NewDeps(client)
require.NoError(t, err)
tmpdir := os.TempDir()
dirPath := filepath.Join(tmpdir, "dir1/dir2")
err = fs.MkdirAll(dirPath, 0o755)
require.NoError(t, err)
filePath := filepath.Join(tmpdir, "dir1", "foo")
err = afero.WriteFile(fs, filePath, []byte("foo bar"), 0o644)
require.NoError(t, err)
_, err = testTool(t, toolsdk.WorkspaceLS, tb, toolsdk.WorkspaceLSArgs{
Workspace: workspace.Name,
Path: "relative",
})
require.Error(t, err)
require.Contains(t, err.Error(), "path must be absolute")
res, err := testTool(t, toolsdk.WorkspaceLS, tb, toolsdk.WorkspaceLSArgs{
Workspace: workspace.Name,
Path: filepath.Dir(dirPath),
})
require.NoError(t, err)
require.Equal(t, []toolsdk.WorkspaceLSFile{
{
Path: dirPath,
IsDir: true,
},
{
Path: filePath,
IsDir: false,
},
}, res.Contents)
})
t.Run("WorkspaceReadFile", func(t *testing.T) {
t.Parallel()
+55
View File
@@ -61,6 +61,7 @@ type AgentConn interface {
PrometheusMetrics(ctx context.Context) ([]byte, error)
ReconnectingPTY(ctx context.Context, id uuid.UUID, height uint16, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error)
RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error)
LS(ctx context.Context, path string, req LSRequest) (LSResponse, error)
ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error)
WriteFile(ctx context.Context, path string, reader io.Reader) error
EditFiles(ctx context.Context, edits FileEditRequest) error
@@ -480,6 +481,60 @@ func (c *agentConn) RecreateDevcontainer(ctx context.Context, devcontainerID str
return m, nil
}
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 LSRelativity string
const (
LSRelativityRoot LSRelativity = "root"
LSRelativityHome LSRelativity = "home"
)
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"`
}
// LS lists a directory.
func (c *agentConn) LS(ctx context.Context, path string, req LSRequest) (LSResponse, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
res, err := c.apiRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v0/list-directory?path=%s", path), req)
if err != nil {
return LSResponse{}, xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return LSResponse{}, codersdk.ReadBodyAsError(res)
}
var m LSResponse
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
return LSResponse{}, xerrors.Errorf("decode response body: %w", err)
}
return m, nil
}
// ReadFile reads from a file from the workspace, returning a file reader and
// the mime type.
func (c *agentConn) ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error) {
@@ -169,6 +169,21 @@ func (mr *MockAgentConnMockRecorder) GetPeerDiagnostics() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerDiagnostics", reflect.TypeOf((*MockAgentConn)(nil).GetPeerDiagnostics))
}
// LS mocks base method.
func (m *MockAgentConn) LS(ctx context.Context, path string, req workspacesdk.LSRequest) (workspacesdk.LSResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LS", ctx, path, req)
ret0, _ := ret[0].(workspacesdk.LSResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LS indicates an expected call of LS.
func (mr *MockAgentConnMockRecorder) LS(ctx, path, req any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LS", reflect.TypeOf((*MockAgentConn)(nil).LS), ctx, path, req)
}
// ListContainers mocks base method.
func (m *MockAgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
m.ctrl.T.Helper()