mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +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")
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user