diff --git a/agent/ls.go b/agent/ls.go index 29392795d3f1c..f2e2b27ea7902 100644 --- a/agent/ls.go +++ b/agent/ls.go @@ -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" -) diff --git a/agent/ls_internal_test.go b/agent/ls_internal_test.go index 0c4e42f2d0cc9..18b959e5f8364 100644 --- a/agent/ls_internal_test.go +++ b/agent/ls_internal_test.go @@ -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") } diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 12ab40e714f0f..42ae80901519b 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -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"` diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index db0d65c02c0ee..69ca9212a0553 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -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() diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 4fc6bc15266fb..dbfb833e44525 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -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) { diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index cf7050de0cd4e..cf6b4c72bea27 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -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()