From 48a195631ec1e7e5b8c5c50562243d77c3049fa9 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 28 Aug 2025 08:53:12 -0800 Subject: [PATCH 1/5] Add coder_workspace_edit_file MCP tool --- agent/api.go | 1 + agent/files.go | 92 ++++++++++ agent/files_test.go | 165 ++++++++++++++++++ codersdk/toolsdk/toolsdk.go | 65 +++++++ codersdk/toolsdk/toolsdk_test.go | 41 +++++ codersdk/workspacesdk/agentconn.go | 56 +++++- .../agentconnmock/agentconnmock.go | 14 ++ go.mod | 1 + go.sum | 5 + 9 files changed, 438 insertions(+), 2 deletions(-) diff --git a/agent/api.go b/agent/api.go index bb3adc9e2457c..205e1bca8248f 100644 --- a/agent/api.go +++ b/agent/api.go @@ -62,6 +62,7 @@ func (a *agent) apiHandler() http.Handler { r.Post("/api/v0/list-directory", a.HandleLS) r.Get("/api/v0/read-file", a.HandleReadFile) r.Post("/api/v0/write-file", a.HandleWriteFile) + r.Post("/api/v0/edit-file", a.HandleEditFile) r.Get("/debug/logs", a.HandleHTTPDebugLogs) r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock) r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState) diff --git a/agent/files.go b/agent/files.go index 2f6a217093640..8c7c1eed6e242 100644 --- a/agent/files.go +++ b/agent/files.go @@ -12,11 +12,15 @@ import ( "strconv" "syscall" + "github.com/icholy/replace" + "github.com/spf13/afero" + "golang.org/x/text/transform" "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" ) type HTTPResponseCode = int @@ -165,3 +169,91 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT return 0, nil } + +func (a *agent) HandleEditFile(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + query := r.URL.Query() + parser := httpapi.NewQueryParamParser().RequiredNotEmpty("path") + 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 + } + + var edits workspacesdk.FileEditRequest + if !httpapi.Read(ctx, rw, r, &edits) { + return + } + + status, err := a.editFile(path, edits.Edits) + if err != nil { + httpapi.Write(ctx, rw, status, codersdk.Response{ + Message: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ + Message: fmt.Sprintf("Successfully edited %q", path), + }) +} + +func (a *agent) editFile(path string, edits []workspacesdk.FileEdit) (int, error) { + if !filepath.IsAbs(path) { + return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path) + } + + f, err := a.filesystem.Open(path) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, os.ErrNotExist): + status = http.StatusNotFound + case errors.Is(err, os.ErrPermission): + status = http.StatusForbidden + } + return status, err + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return http.StatusInternalServerError, err + } + + if stat.IsDir() { + return http.StatusBadRequest, xerrors.Errorf("open %s: not a file", path) + } + + if len(edits) == 0 { + return http.StatusBadRequest, xerrors.New("must specify at least one edit") + } + + transforms := make([]transform.Transformer, len(edits)) + for i, edit := range edits { + transforms[i] = replace.String(edit.Search, edit.Replace) + } + + tmpfile, err := afero.TempFile(a.filesystem, "", filepath.Base(path)) + if err != nil { + return http.StatusInternalServerError, err + } + defer tmpfile.Close() + + _, err = io.Copy(tmpfile, replace.Chain(f, transforms...)) + if err != nil { + return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err) + } + + err = a.filesystem.Rename(tmpfile.Name(), path) + if err != nil { + return http.StatusInternalServerError, err + } + + return 0, nil +} diff --git a/agent/files_test.go b/agent/files_test.go index e443f27e73e2b..d4bd67395dcf8 100644 --- a/agent/files_test.go +++ b/agent/files_test.go @@ -13,11 +13,13 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/testutil" ) @@ -91,6 +93,13 @@ func (fs *testFs) MkdirAll(name string, mode os.FileMode) error { return fs.Fs.MkdirAll(name, mode) } +func (fs *testFs) Rename(oldName, newName string) error { + if err := fs.intercept("rename", newName); err != nil { + return err + } + return fs.Fs.Rename(oldName, newName) +} + func TestReadFile(t *testing.T) { t.Parallel() @@ -376,3 +385,159 @@ func TestWriteFile(t *testing.T) { }) } } + +func TestEditFile(t *testing.T) { + t.Parallel() + + tmpdir := os.TempDir() + noPermsFilePath := filepath.Join(tmpdir, "no-perms-file") + failRenameFilePath := filepath.Join(tmpdir, "fail-rename") + //nolint:dogsled + conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) { + opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error { + if file == noPermsFilePath { + return os.ErrPermission + } else if file == failRenameFilePath && call == "rename" { + return xerrors.New("rename failed") + } + return nil + }) + }) + + dirPath := filepath.Join(tmpdir, "directory") + err := fs.MkdirAll(dirPath, 0o755) + require.NoError(t, err) + + tests := []struct { + name string + path string + contents string + edits []workspacesdk.FileEdit + expected string + errCode int + error string + }{ + { + name: "NoPath", + errCode: http.StatusBadRequest, + error: "\"path\" is required", + }, + { + name: "RelativePath", + path: "./relative", + errCode: http.StatusBadRequest, + error: "file path must be absolute", + }, + { + name: "RelativePath", + path: "also-relative", + errCode: http.StatusBadRequest, + error: "file path must be absolute", + }, + { + name: "NonExistent", + path: filepath.Join(tmpdir, "does-not-exist"), + errCode: http.StatusNotFound, + error: "file does not exist", + }, + { + name: "IsDir", + path: dirPath, + errCode: http.StatusBadRequest, + error: "not a file", + }, + { + name: "NoPermissions", + path: noPermsFilePath, + errCode: http.StatusForbidden, + error: "permission denied", + }, + { + name: "NoEdits", + path: filepath.Join(tmpdir, "no-edits"), + contents: "foo bar", + errCode: http.StatusBadRequest, + error: "must specify at least one edit", + }, + { + name: "FailRename", + path: failRenameFilePath, + contents: "foo bar", + edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + errCode: http.StatusInternalServerError, + error: "rename failed", + }, + { + name: "Edit1", + path: filepath.Join(tmpdir, "edit1"), + contents: "foo bar", + edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + expected: "bar bar", + }, + { + name: "EditEdit", // Edits affect previous edits. + path: filepath.Join(tmpdir, "edit-edit"), + contents: "foo bar", + edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + { + Search: "bar", + Replace: "qux", + }, + }, + expected: "qux qux", + }, + { + name: "Multiline", + path: filepath.Join(tmpdir, "multiline"), + contents: "foo\nbar\nbaz\nqux", + edits: []workspacesdk.FileEdit{ + { + Search: "bar\nbaz", + Replace: "frob", + }, + }, + expected: "foo\nfrob\nqux", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + if tt.contents != "" { + err := afero.WriteFile(fs, tt.path, []byte(tt.contents), 0o644) + require.NoError(t, err) + } + + err := conn.EditFile(ctx, tt.path, workspacesdk.FileEditRequest{Edits: tt.edits}) + if tt.errCode != 0 { + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Contains(t, cerr.Error(), tt.error) + require.Equal(t, tt.errCode, cerr.StatusCode()) + } else { + require.NoError(t, err) + b, err := afero.ReadFile(fs, tt.path) + require.NoError(t, err) + require.Equal(t, tt.expected, string(b)) + } + }) + } +} diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 46c296c0535aa..ad3ea05cfc424 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -44,6 +44,7 @@ const ( ToolNameChatGPTFetch = "fetch" ToolNameWorkspaceReadFile = "coder_workspace_read_file" ToolNameWorkspaceWriteFile = "coder_workspace_write_file" + ToolNameWorkspaceEditFile = "coder_workspace_edit_file" ) func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) { @@ -213,6 +214,7 @@ var All = []GenericTool{ ChatGPTFetch.Generic(), WorkspaceReadFile.Generic(), WorkspaceWriteFile.Generic(), + WorkspaceEditFile.Generic(), } type ReportTaskArgs struct { @@ -1491,6 +1493,69 @@ var WorkspaceWriteFile = Tool[WorkspaceWriteFileArgs, codersdk.Response]{ }, } +type WorkspaceEditFileArgs struct { + Workspace string `json:"workspace"` + Path string `json:"path"` + Edits []workspacesdk.FileEdit `json:"edits"` +} + +var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{ + Tool: aisdk.Tool{ + Name: ToolNameWorkspaceEditFile, + Description: `Edit a file 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 file to write in the workspace.", + }, + "edits": map[string]any{ + "type": "array", + "description": "An array of edit operations.", + "items": []any{ + map[string]any{ + "type": "object", + "properties": map[string]any{ + "search": map[string]any{ + "type": "string", + "description": "The old string to replace.", + }, + "replace": map[string]any{ + "type": "string", + "description": "The new string that replaces the old string.", + }, + }, + "required": []string{"search", "replace"}, + }, + }, + }, + }, + Required: []string{"path", "workspace", "edits"}, + }, + }, + UserClientOptional: true, + Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFileArgs) (codersdk.Response, error) { + conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace) + if err != nil { + return codersdk.Response{}, err + } + defer conn.Close() + + err = conn.EditFile(ctx, args.Path, workspacesdk.FileEditRequest{Edits: args.Edits}) + if err != nil { + return codersdk.Response{}, err + } + + return codersdk.Response{ + Message: "File edited successfully.", + }, nil + }, +} + // NormalizeWorkspaceInput converts workspace name input to standard format. // Handles the following input formats: // - workspace → workspace diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index 0030549f5eea2..d5d6afbdcb737 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -28,6 +28,7 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/toolsdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" ) @@ -579,6 +580,46 @@ func TestTools(t *testing.T) { require.NoError(t, err) require.Equal(t, []byte("content"), b) }) + + t.Run("WorkspaceEditFile", 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() + filePath := filepath.Join(tmpdir, "edit") + err = afero.WriteFile(fs, filePath, []byte("foo bar"), 0o644) + require.NoError(t, err) + + _, err = testTool(t, toolsdk.WorkspaceEditFile, tb, toolsdk.WorkspaceEditFileArgs{ + Workspace: workspace.Name, + Path: filePath, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "must specify at least one edit") + + _, err = testTool(t, toolsdk.WorkspaceEditFile, tb, toolsdk.WorkspaceEditFileArgs{ + Workspace: workspace.Name, + Path: filePath, + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }) + require.NoError(t, err) + b, err := afero.ReadFile(fs, filePath) + require.NoError(t, err) + require.Equal(t, "bar bar", string(b)) + }) } // TestedTools keeps track of which tools have been tested. diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 0afb6f0c868a8..20769cbf54425 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -1,6 +1,7 @@ package workspacesdk import ( + "bytes" "context" "encoding/binary" "encoding/json" @@ -62,6 +63,7 @@ type AgentConn interface { RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error) WriteFile(ctx context.Context, path string, reader io.Reader) error + EditFile(ctx context.Context, path string, edits FileEditRequest) error SSH(ctx context.Context) (*gonet.TCPConn, error) SSHClient(ctx context.Context) (*ssh.Client, error) SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error) @@ -523,15 +525,65 @@ func (c *agentConn) WriteFile(ctx context.Context, path string, reader io.Reader return nil } +type FileEdit struct { + Search string `json:"search"` + Replace string `json:"replace"` +} + +type FileEditRequest struct { + Edits []FileEdit `json:"edits"` +} + +// EditFile performs search and replace edits on a file. +func (c *agentConn) EditFile(ctx context.Context, path string, edits FileEditRequest) error { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + res, err := c.apiRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v0/edit-file?path=%s", path), edits) + if err != nil { + return xerrors.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return codersdk.ReadBodyAsError(res) + } + + var m codersdk.Response + if err := json.NewDecoder(res.Body).Decode(&m); err != nil { + return xerrors.Errorf("decode response body: %w", err) + } + return nil +} + // apiRequest makes a request to the workspace agent's HTTP API server. -func (c *agentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { +func (c *agentConn) apiRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort)) url := fmt.Sprintf("http://%s%s", host, path) - req, err := http.NewRequestWithContext(ctx, method, url, body) + var r io.Reader + if body != nil { + switch data := body.(type) { + case io.Reader: + r = data + case []byte: + r = bytes.NewReader(data) + default: + // Assume JSON in all other cases. + buf := bytes.NewBuffer(nil) + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + err := enc.Encode(body) + if err != nil { + return nil, xerrors.Errorf("encode body: %w", err) + } + r = buf + } + } + + req, err := http.NewRequestWithContext(ctx, method, url, r) if err != nil { return nil, xerrors.Errorf("new http api request to %q: %w", url, err) } diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index 4956be0c26c2b..8d0a90af6cee0 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -141,6 +141,20 @@ func (mr *MockAgentConnMockRecorder) DialContext(ctx, network, addr any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DialContext", reflect.TypeOf((*MockAgentConn)(nil).DialContext), ctx, network, addr) } +// EditFile mocks base method. +func (m *MockAgentConn) EditFile(ctx context.Context, path string, edits workspacesdk.FileEditRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EditFile", ctx, path, edits) + ret0, _ := ret[0].(error) + return ret0 +} + +// EditFile indicates an expected call of EditFile. +func (mr *MockAgentConnMockRecorder) EditFile(ctx, path, edits any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditFile", reflect.TypeOf((*MockAgentConn)(nil).EditFile), ctx, path, edits) +} + // GetPeerDiagnostics mocks base method. func (m *MockAgentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics { m.ctrl.T.Helper() diff --git a/go.mod b/go.mod index 76ce2f0eaa5a3..5559ba38da894 100644 --- a/go.mod +++ b/go.mod @@ -482,6 +482,7 @@ require ( github.com/coder/preview v1.0.4 github.com/fsnotify/fsnotify v1.9.0 github.com/go-git/go-git/v5 v5.16.2 + github.com/icholy/replace v0.6.0 github.com/mark3labs/mcp-go v0.32.0 ) diff --git a/go.sum b/go.sum index c21559c9ebe18..07bf13bb75520 100644 --- a/go.sum +++ b/go.sum @@ -1425,6 +1425,8 @@ github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJ github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/icholy/replace v0.6.0 h1:EBiD2pGqZIOJAbEaf/5GVRaD/Pmbb4n+K3LrBdXd4dw= +github.com/icholy/replace v0.6.0/go.mod h1:zzi8pxElj2t/5wHHHYmH45D+KxytX/t4w3ClY5nlK+g= github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= @@ -1757,6 +1759,7 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= @@ -2379,6 +2382,7 @@ golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -2752,6 +2756,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc h1:DXLLFYv/k/xr0rWcwVEvWme1GR36Oc4kNMspg38JeiE= From c63c4a9dc8b76c57376aafb01d52cef4a4fba02b Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 11 Sep 2025 14:48:01 -0800 Subject: [PATCH 2/5] Move edit length check above file open --- agent/files.go | 8 ++++---- agent/files_test.go | 44 +++++++++++++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/agent/files.go b/agent/files.go index 8c7c1eed6e242..6479e3da84cb9 100644 --- a/agent/files.go +++ b/agent/files.go @@ -208,6 +208,10 @@ func (a *agent) editFile(path string, edits []workspacesdk.FileEdit) (int, error return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path) } + if len(edits) == 0 { + return http.StatusBadRequest, xerrors.New("must specify at least one edit") + } + f, err := a.filesystem.Open(path) if err != nil { status := http.StatusInternalServerError @@ -230,10 +234,6 @@ func (a *agent) editFile(path string, edits []workspacesdk.FileEdit) (int, error return http.StatusBadRequest, xerrors.Errorf("open %s: not a file", path) } - if len(edits) == 0 { - return http.StatusBadRequest, xerrors.New("must specify at least one edit") - } - transforms := make([]transform.Transformer, len(edits)) for i, edit := range edits { transforms[i] = replace.String(edit.Search, edit.Replace) diff --git a/agent/files_test.go b/agent/files_test.go index d4bd67395dcf8..cacfae9a2824a 100644 --- a/agent/files_test.go +++ b/agent/files_test.go @@ -435,30 +435,48 @@ func TestEditFile(t *testing.T) { error: "file path must be absolute", }, { - name: "NonExistent", - path: filepath.Join(tmpdir, "does-not-exist"), + name: "NoEdits", + path: filepath.Join(tmpdir, "no-edits"), + contents: "foo bar", + errCode: http.StatusBadRequest, + error: "must specify at least one edit", + }, + { + name: "NonExistent", + path: filepath.Join(tmpdir, "does-not-exist"), + edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, errCode: http.StatusNotFound, error: "file does not exist", }, { - name: "IsDir", - path: dirPath, + name: "IsDir", + path: dirPath, + edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, errCode: http.StatusBadRequest, error: "not a file", }, { - name: "NoPermissions", - path: noPermsFilePath, + name: "NoPermissions", + path: noPermsFilePath, + edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, errCode: http.StatusForbidden, error: "permission denied", }, - { - name: "NoEdits", - path: filepath.Join(tmpdir, "no-edits"), - contents: "foo bar", - errCode: http.StatusBadRequest, - error: "must specify at least one edit", - }, { name: "FailRename", path: failRenameFilePath, From f13896b7b733ab31ba32fc96c21a631299f21df6 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 11 Sep 2025 15:14:17 -0800 Subject: [PATCH 3/5] Clean up temp file if copy fails --- agent/files.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/agent/files.go b/agent/files.go index 6479e3da84cb9..75295cc0488c0 100644 --- a/agent/files.go +++ b/agent/files.go @@ -190,7 +190,7 @@ func (a *agent) HandleEditFile(rw http.ResponseWriter, r *http.Request) { return } - status, err := a.editFile(path, edits.Edits) + status, err := a.editFile(r.Context(), path, edits.Edits) if err != nil { httpapi.Write(ctx, rw, status, codersdk.Response{ Message: err.Error(), @@ -203,7 +203,7 @@ func (a *agent) HandleEditFile(rw http.ResponseWriter, r *http.Request) { }) } -func (a *agent) editFile(path string, edits []workspacesdk.FileEdit) (int, error) { +func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) { if !filepath.IsAbs(path) { return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path) } @@ -247,6 +247,9 @@ func (a *agent) editFile(path string, edits []workspacesdk.FileEdit) (int, error _, err = io.Copy(tmpfile, replace.Chain(f, transforms...)) if err != nil { + if rerr := a.filesystem.Remove(tmpfile.Name()); rerr != nil { + a.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr)) + } return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err) } From 276da3437216c9ceaf76623a71a6112da57dac81 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 11 Sep 2025 16:36:09 -0800 Subject: [PATCH 4/5] Support editing multiple files --- agent/api.go | 2 +- agent/files.go | 41 ++- agent/files_test.go | 295 ++++++++++++++---- codersdk/toolsdk/toolsdk.go | 9 +- codersdk/workspacesdk/agentconn.go | 15 +- .../agentconnmock/agentconnmock.go | 12 +- 6 files changed, 279 insertions(+), 95 deletions(-) diff --git a/agent/api.go b/agent/api.go index 205e1bca8248f..f417a046c24a6 100644 --- a/agent/api.go +++ b/agent/api.go @@ -62,7 +62,7 @@ func (a *agent) apiHandler() http.Handler { r.Post("/api/v0/list-directory", a.HandleLS) r.Get("/api/v0/read-file", a.HandleReadFile) r.Post("/api/v0/write-file", a.HandleWriteFile) - r.Post("/api/v0/edit-file", a.HandleEditFile) + r.Post("/api/v0/edit-files", a.HandleEditFiles) r.Get("/debug/logs", a.HandleHTTPDebugLogs) r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock) r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState) diff --git a/agent/files.go b/agent/files.go index 75295cc0488c0..f2a9ac6edc581 100644 --- a/agent/files.go +++ b/agent/files.go @@ -170,40 +170,51 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT return 0, nil } -func (a *agent) HandleEditFile(rw http.ResponseWriter, r *http.Request) { +func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - query := r.URL.Query() - parser := httpapi.NewQueryParamParser().RequiredNotEmpty("path") - path := parser.String(query, "", "path") - parser.ErrorExcessParams(query) - if len(parser.Errors) > 0 { + var req workspacesdk.FileEditRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if len(req.Files) == 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Query parameters have invalid values.", - Validations: parser.Errors, + Message: "must specify at least one file", }) return } - var edits workspacesdk.FileEditRequest - if !httpapi.Read(ctx, rw, r, &edits) { - return + var combinedErr error + status := http.StatusOK + for _, edit := range req.Files { + s, err := a.editFile(r.Context(), edit.Path, edit.Edits) + // Keep the highest response status, so 500 will be preferred over 400, etc. + if s > status { + status = s + } + if err != nil { + combinedErr = errors.Join(combinedErr, err) + } } - status, err := a.editFile(r.Context(), path, edits.Edits) - if err != nil { + if combinedErr != nil { httpapi.Write(ctx, rw, status, codersdk.Response{ - Message: err.Error(), + Message: combinedErr.Error(), }) return } httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ - Message: fmt.Sprintf("Successfully edited %q", path), + Message: "Successfully edited file(s)", }) } func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) { + if path == "" { + return http.StatusBadRequest, xerrors.New("\"path\" is required") + } + if !filepath.IsAbs(path) { return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path) } diff --git a/agent/files_test.go b/agent/files_test.go index cacfae9a2824a..969c9b053bd6e 100644 --- a/agent/files_test.go +++ b/agent/files_test.go @@ -3,6 +3,7 @@ package agent_test import ( "bytes" "context" + "fmt" "io" "net/http" "os" @@ -386,7 +387,7 @@ func TestWriteFile(t *testing.T) { } } -func TestEditFile(t *testing.T) { +func TestEditFiles(t *testing.T) { t.Parallel() tmpdir := os.TempDir() @@ -396,7 +397,11 @@ func TestEditFile(t *testing.T) { conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) { opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error { if file == noPermsFilePath { - return os.ErrPermission + return &os.PathError{ + Op: call, + Path: file, + Err: os.ErrPermission, + } } else if file == failRenameFilePath && call == "rename" { return xerrors.New("rename failed") } @@ -410,125 +415,277 @@ func TestEditFile(t *testing.T) { tests := []struct { name string - path string - contents string - edits []workspacesdk.FileEdit - expected string + contents map[string]string + edits []workspacesdk.FileEdits + expected map[string]string errCode int - error string + errors []string }{ + { + name: "NoFiles", + errCode: http.StatusBadRequest, + errors: []string{"must specify at least one file"}, + }, { name: "NoPath", errCode: http.StatusBadRequest, - error: "\"path\" is required", + edits: []workspacesdk.FileEdits{ + { + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, + errors: []string{"\"path\" is required"}, }, { - name: "RelativePath", - path: "./relative", + name: "RelativePathDotSlash", + edits: []workspacesdk.FileEdits{ + { + Path: "./relative", + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, errCode: http.StatusBadRequest, - error: "file path must be absolute", + errors: []string{"file path must be absolute"}, }, { - name: "RelativePath", - path: "also-relative", + name: "RelativePath", + edits: []workspacesdk.FileEdits{ + { + Path: "also-relative", + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, errCode: http.StatusBadRequest, - error: "file path must be absolute", + errors: []string{"file path must be absolute"}, }, { - name: "NoEdits", - path: filepath.Join(tmpdir, "no-edits"), - contents: "foo bar", - errCode: http.StatusBadRequest, - error: "must specify at least one edit", + name: "NoEdits", + edits: []workspacesdk.FileEdits{ + { + Path: filepath.Join(tmpdir, "no-edits"), + }, + }, + errCode: http.StatusBadRequest, + errors: []string{"must specify at least one edit"}, }, { name: "NonExistent", - path: filepath.Join(tmpdir, "does-not-exist"), - edits: []workspacesdk.FileEdit{ + edits: []workspacesdk.FileEdits{ { - Search: "foo", - Replace: "bar", + Path: filepath.Join(tmpdir, "does-not-exist"), + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, }, }, errCode: http.StatusNotFound, - error: "file does not exist", + errors: []string{"file does not exist"}, }, { name: "IsDir", - path: dirPath, - edits: []workspacesdk.FileEdit{ + edits: []workspacesdk.FileEdits{ { - Search: "foo", - Replace: "bar", + Path: dirPath, + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, }, }, errCode: http.StatusBadRequest, - error: "not a file", + errors: []string{"not a file"}, }, { name: "NoPermissions", - path: noPermsFilePath, - edits: []workspacesdk.FileEdit{ + edits: []workspacesdk.FileEdits{ { - Search: "foo", - Replace: "bar", + Path: noPermsFilePath, + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, }, }, errCode: http.StatusForbidden, - error: "permission denied", + errors: []string{"permission denied"}, }, { name: "FailRename", - path: failRenameFilePath, - contents: "foo bar", - edits: []workspacesdk.FileEdit{ + contents: map[string]string{failRenameFilePath: "foo bar"}, + edits: []workspacesdk.FileEdits{ { - Search: "foo", - Replace: "bar", + Path: failRenameFilePath, + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, }, }, errCode: http.StatusInternalServerError, - error: "rename failed", + errors: []string{"rename failed"}, }, { name: "Edit1", - path: filepath.Join(tmpdir, "edit1"), - contents: "foo bar", - edits: []workspacesdk.FileEdit{ + contents: map[string]string{filepath.Join(tmpdir, "edit1"): "foo bar"}, + edits: []workspacesdk.FileEdits{ { - Search: "foo", - Replace: "bar", + Path: filepath.Join(tmpdir, "edit1"), + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, }, }, - expected: "bar bar", + expected: map[string]string{filepath.Join(tmpdir, "edit1"): "bar bar"}, }, { name: "EditEdit", // Edits affect previous edits. - path: filepath.Join(tmpdir, "edit-edit"), - contents: "foo bar", - edits: []workspacesdk.FileEdit{ + contents: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"}, + edits: []workspacesdk.FileEdits{ { - Search: "foo", - Replace: "bar", + Path: filepath.Join(tmpdir, "edit-edit"), + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + { + Search: "bar", + Replace: "qux", + }, + }, }, + }, + expected: map[string]string{filepath.Join(tmpdir, "edit-edit"): "qux qux"}, + }, + { + name: "Multiline", + contents: map[string]string{filepath.Join(tmpdir, "multiline"): "foo\nbar\nbaz\nqux"}, + edits: []workspacesdk.FileEdits{ { - Search: "bar", - Replace: "qux", + Path: filepath.Join(tmpdir, "multiline"), + Edits: []workspacesdk.FileEdit{ + { + Search: "bar\nbaz", + Replace: "frob", + }, + }, }, }, - expected: "qux qux", + expected: map[string]string{filepath.Join(tmpdir, "multiline"): "foo\nfrob\nqux"}, }, { - name: "Multiline", - path: filepath.Join(tmpdir, "multiline"), - contents: "foo\nbar\nbaz\nqux", - edits: []workspacesdk.FileEdit{ + name: "Multifile", + contents: map[string]string{ + filepath.Join(tmpdir, "file1"): "file 1", + filepath.Join(tmpdir, "file2"): "file 2", + filepath.Join(tmpdir, "file3"): "file 3", + }, + edits: []workspacesdk.FileEdits{ + { + Path: filepath.Join(tmpdir, "file1"), + Edits: []workspacesdk.FileEdit{ + { + Search: "file", + Replace: "edited1", + }, + }, + }, + { + Path: filepath.Join(tmpdir, "file2"), + Edits: []workspacesdk.FileEdit{ + { + Search: "file", + Replace: "edited2", + }, + }, + }, + { + Path: filepath.Join(tmpdir, "file3"), + Edits: []workspacesdk.FileEdit{ + { + Search: "file", + Replace: "edited3", + }, + }, + }, + }, + expected: map[string]string{ + filepath.Join(tmpdir, "file1"): "edited1 1", + filepath.Join(tmpdir, "file2"): "edited2 2", + filepath.Join(tmpdir, "file3"): "edited3 3", + }, + }, + { + name: "MultiError", + contents: map[string]string{ + filepath.Join(tmpdir, "file8"): "file 8", + }, + edits: []workspacesdk.FileEdits{ + { + Path: noPermsFilePath, + Edits: []workspacesdk.FileEdit{ + { + Search: "file", + Replace: "edited7", + }, + }, + }, + { + Path: filepath.Join(tmpdir, "file8"), + Edits: []workspacesdk.FileEdit{ + { + Search: "file", + Replace: "edited8", + }, + }, + }, { - Search: "bar\nbaz", - Replace: "frob", + Path: filepath.Join(tmpdir, "file9"), + Edits: []workspacesdk.FileEdit{ + { + Search: "file", + Replace: "edited9", + }, + }, }, }, - expected: "foo\nfrob\nqux", + expected: map[string]string{ + filepath.Join(tmpdir, "file8"): "edited8 8", + }, + // Higher status codes will override lower ones, so in this case the 404 + // takes priority over the 403. + errCode: http.StatusNotFound, + errors: []string{ + fmt.Sprintf("%s: permission denied", noPermsFilePath), + "file9: file does not exist", + }, }, } @@ -539,22 +696,26 @@ func TestEditFile(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - if tt.contents != "" { - err := afero.WriteFile(fs, tt.path, []byte(tt.contents), 0o644) + for path, content := range tt.contents { + err := afero.WriteFile(fs, path, []byte(content), 0o644) require.NoError(t, err) } - err := conn.EditFile(ctx, tt.path, workspacesdk.FileEditRequest{Edits: tt.edits}) + err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: tt.edits}) if tt.errCode != 0 { require.Error(t, err) cerr := coderdtest.SDKError(t, err) - require.Contains(t, cerr.Error(), tt.error) + for _, error := range tt.errors { + require.Contains(t, cerr.Error(), error) + } require.Equal(t, tt.errCode, cerr.StatusCode()) } else { require.NoError(t, err) - b, err := afero.ReadFile(fs, tt.path) + } + for path, expect := range tt.expected { + b, err := afero.ReadFile(fs, path) require.NoError(t, err) - require.Equal(t, tt.expected, string(b)) + require.Equal(t, expect, string(b)) } }) } diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index ad3ea05cfc424..c6bf37a210cd7 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -1545,7 +1545,14 @@ var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{ } defer conn.Close() - err = conn.EditFile(ctx, args.Path, workspacesdk.FileEditRequest{Edits: args.Edits}) + err = conn.EditFiles(ctx, workspacesdk.FileEditRequest{ + Files: []workspacesdk.FileEdits{ + { + Path: args.Path, + Edits: args.Edits, + }, + }, + }) if err != nil { return codersdk.Response{}, err } diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 20769cbf54425..4fc6bc15266fb 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -63,7 +63,7 @@ type AgentConn interface { RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error) WriteFile(ctx context.Context, path string, reader io.Reader) error - EditFile(ctx context.Context, path string, edits FileEditRequest) error + EditFiles(ctx context.Context, edits FileEditRequest) error SSH(ctx context.Context) (*gonet.TCPConn, error) SSHClient(ctx context.Context) (*ssh.Client, error) SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error) @@ -530,16 +530,21 @@ type FileEdit struct { Replace string `json:"replace"` } -type FileEditRequest struct { +type FileEdits struct { + Path string `json:"path"` Edits []FileEdit `json:"edits"` } -// EditFile performs search and replace edits on a file. -func (c *agentConn) EditFile(ctx context.Context, path string, edits FileEditRequest) error { +type FileEditRequest struct { + Files []FileEdits `json:"files"` +} + +// EditFiles performs search and replace edits on one or more files. +func (c *agentConn) EditFiles(ctx context.Context, edits FileEditRequest) error { ctx, span := tracing.StartSpan(ctx) defer span.End() - res, err := c.apiRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v0/edit-file?path=%s", path), edits) + res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/edit-files", edits) if err != nil { return xerrors.Errorf("do request: %w", err) } diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index 8d0a90af6cee0..cf7050de0cd4e 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -141,18 +141,18 @@ func (mr *MockAgentConnMockRecorder) DialContext(ctx, network, addr any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DialContext", reflect.TypeOf((*MockAgentConn)(nil).DialContext), ctx, network, addr) } -// EditFile mocks base method. -func (m *MockAgentConn) EditFile(ctx context.Context, path string, edits workspacesdk.FileEditRequest) error { +// EditFiles mocks base method. +func (m *MockAgentConn) EditFiles(ctx context.Context, edits workspacesdk.FileEditRequest) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EditFile", ctx, path, edits) + ret := m.ctrl.Call(m, "EditFiles", ctx, edits) ret0, _ := ret[0].(error) return ret0 } -// EditFile indicates an expected call of EditFile. -func (mr *MockAgentConnMockRecorder) EditFile(ctx, path, edits any) *gomock.Call { +// EditFiles indicates an expected call of EditFiles. +func (mr *MockAgentConnMockRecorder) EditFiles(ctx, edits any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditFile", reflect.TypeOf((*MockAgentConn)(nil).EditFile), ctx, path, edits) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditFiles", reflect.TypeOf((*MockAgentConn)(nil).EditFiles), ctx, edits) } // GetPeerDiagnostics mocks base method. From 40a84954df8627968469dd6d3b5f2533f2462d95 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 11 Sep 2025 16:43:54 -0800 Subject: [PATCH 5/5] Add coder_workspace_edit_files MCP tool --- codersdk/toolsdk/toolsdk.go | 76 ++++++++++++++++++++++++++++++++ codersdk/toolsdk/toolsdk_test.go | 61 +++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index c6bf37a210cd7..12ab40e714f0f 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -45,6 +45,7 @@ const ( ToolNameWorkspaceReadFile = "coder_workspace_read_file" ToolNameWorkspaceWriteFile = "coder_workspace_write_file" ToolNameWorkspaceEditFile = "coder_workspace_edit_file" + ToolNameWorkspaceEditFiles = "coder_workspace_edit_files" ) func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) { @@ -215,6 +216,7 @@ var All = []GenericTool{ WorkspaceReadFile.Generic(), WorkspaceWriteFile.Generic(), WorkspaceEditFile.Generic(), + WorkspaceEditFiles.Generic(), } type ReportTaskArgs struct { @@ -1563,6 +1565,80 @@ var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{ }, } +type WorkspaceEditFilesArgs struct { + Workspace string `json:"workspace"` + Files []workspacesdk.FileEdits `json:"files"` +} + +var WorkspaceEditFiles = Tool[WorkspaceEditFilesArgs, codersdk.Response]{ + Tool: aisdk.Tool{ + Name: ToolNameWorkspaceEditFiles, + Description: `Edit one or more files 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.", + }, + "files": map[string]any{ + "type": "array", + "description": "An array of files to edit.", + "items": []any{ + map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "The absolute path of the file to write in the workspace.", + }, + "edits": map[string]any{ + "type": "array", + "description": "An array of edit operations.", + "items": []any{ + map[string]any{ + "type": "object", + "properties": map[string]any{ + "search": map[string]any{ + "type": "string", + "description": "The old string to replace.", + }, + "replace": map[string]any{ + "type": "string", + "description": "The new string that replaces the old string.", + }, + }, + "required": []string{"search", "replace"}, + }, + }, + }, + "required": []string{"path", "edits"}, + }, + }, + }, + }, + }, + Required: []string{"workspace", "files"}, + }, + }, + UserClientOptional: true, + Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFilesArgs) (codersdk.Response, error) { + conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace) + if err != nil { + return codersdk.Response{}, err + } + defer conn.Close() + + err = conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: args.Files}) + if err != nil { + return codersdk.Response{}, err + } + + return codersdk.Response{ + Message: "File(s) edited successfully.", + }, nil + }, +} + // NormalizeWorkspaceInput converts workspace name input to standard format. // Handles the following input formats: // - workspace → workspace diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index d5d6afbdcb737..db0d65c02c0ee 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -620,6 +620,67 @@ func TestTools(t *testing.T) { require.NoError(t, err) require.Equal(t, "bar bar", string(b)) }) + + t.Run("WorkspaceEditFiles", 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() + filePath1 := filepath.Join(tmpdir, "edit1") + err = afero.WriteFile(fs, filePath1, []byte("foo1 bar1"), 0o644) + require.NoError(t, err) + + filePath2 := filepath.Join(tmpdir, "edit2") + err = afero.WriteFile(fs, filePath2, []byte("foo2 bar2"), 0o644) + require.NoError(t, err) + + _, err = testTool(t, toolsdk.WorkspaceEditFiles, tb, toolsdk.WorkspaceEditFilesArgs{ + Workspace: workspace.Name, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "must specify at least one file") + + _, err = testTool(t, toolsdk.WorkspaceEditFiles, tb, toolsdk.WorkspaceEditFilesArgs{ + Workspace: workspace.Name, + Files: []workspacesdk.FileEdits{ + { + Path: filePath1, + Edits: []workspacesdk.FileEdit{ + { + Search: "foo1", + Replace: "bar1", + }, + }, + }, + { + Path: filePath2, + Edits: []workspacesdk.FileEdit{ + { + Search: "foo2", + Replace: "bar2", + }, + }, + }, + }, + }) + require.NoError(t, err) + + b, err := afero.ReadFile(fs, filePath1) + require.NoError(t, err) + require.Equal(t, "bar1 bar1", string(b)) + + b, err = afero.ReadFile(fs, filePath2) + require.NoError(t, err) + require.Equal(t, "bar2 bar2", string(b)) + }) } // TestedTools keeps track of which tools have been tested.