diff --git a/agent/api.go b/agent/api.go index bb3adc9e2457c..f417a046c24a6 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-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 2f6a217093640..f2a9ac6edc581 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,105 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT return 0, nil } + +func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + 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: "must specify at least one file", + }) + 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) + } + } + + if combinedErr != nil { + httpapi.Write(ctx, rw, status, codersdk.Response{ + Message: combinedErr.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ + 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) + } + + 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 + 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) + } + + 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 { + 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) + } + + 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..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" @@ -13,11 +14,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 +94,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 +386,337 @@ func TestWriteFile(t *testing.T) { }) } } + +func TestEditFiles(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.PathError{ + Op: call, + Path: file, + Err: 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 + contents map[string]string + edits []workspacesdk.FileEdits + expected map[string]string + errCode int + errors []string + }{ + { + name: "NoFiles", + errCode: http.StatusBadRequest, + errors: []string{"must specify at least one file"}, + }, + { + name: "NoPath", + errCode: http.StatusBadRequest, + edits: []workspacesdk.FileEdits{ + { + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, + errors: []string{"\"path\" is required"}, + }, + { + name: "RelativePathDotSlash", + edits: []workspacesdk.FileEdits{ + { + Path: "./relative", + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, + errCode: http.StatusBadRequest, + errors: []string{"file path must be absolute"}, + }, + { + name: "RelativePath", + edits: []workspacesdk.FileEdits{ + { + Path: "also-relative", + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, + errCode: http.StatusBadRequest, + errors: []string{"file path must be absolute"}, + }, + { + name: "NoEdits", + edits: []workspacesdk.FileEdits{ + { + Path: filepath.Join(tmpdir, "no-edits"), + }, + }, + errCode: http.StatusBadRequest, + errors: []string{"must specify at least one edit"}, + }, + { + name: "NonExistent", + edits: []workspacesdk.FileEdits{ + { + Path: filepath.Join(tmpdir, "does-not-exist"), + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, + errCode: http.StatusNotFound, + errors: []string{"file does not exist"}, + }, + { + name: "IsDir", + edits: []workspacesdk.FileEdits{ + { + Path: dirPath, + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, + errCode: http.StatusBadRequest, + errors: []string{"not a file"}, + }, + { + name: "NoPermissions", + edits: []workspacesdk.FileEdits{ + { + Path: noPermsFilePath, + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, + errCode: http.StatusForbidden, + errors: []string{"permission denied"}, + }, + { + name: "FailRename", + contents: map[string]string{failRenameFilePath: "foo bar"}, + edits: []workspacesdk.FileEdits{ + { + Path: failRenameFilePath, + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, + errCode: http.StatusInternalServerError, + errors: []string{"rename failed"}, + }, + { + name: "Edit1", + contents: map[string]string{filepath.Join(tmpdir, "edit1"): "foo bar"}, + edits: []workspacesdk.FileEdits{ + { + Path: filepath.Join(tmpdir, "edit1"), + Edits: []workspacesdk.FileEdit{ + { + Search: "foo", + Replace: "bar", + }, + }, + }, + }, + expected: map[string]string{filepath.Join(tmpdir, "edit1"): "bar bar"}, + }, + { + name: "EditEdit", // Edits affect previous edits. + contents: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"}, + edits: []workspacesdk.FileEdits{ + { + 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{ + { + Path: filepath.Join(tmpdir, "multiline"), + Edits: []workspacesdk.FileEdit{ + { + Search: "bar\nbaz", + Replace: "frob", + }, + }, + }, + }, + expected: map[string]string{filepath.Join(tmpdir, "multiline"): "foo\nfrob\nqux"}, + }, + { + 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", + }, + }, + }, + { + Path: filepath.Join(tmpdir, "file9"), + Edits: []workspacesdk.FileEdit{ + { + Search: "file", + Replace: "edited9", + }, + }, + }, + }, + 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", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + for path, content := range tt.contents { + err := afero.WriteFile(fs, path, []byte(content), 0o644) + require.NoError(t, err) + } + + err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: tt.edits}) + if tt.errCode != 0 { + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + for _, error := range tt.errors { + require.Contains(t, cerr.Error(), error) + } + require.Equal(t, tt.errCode, cerr.StatusCode()) + } else { + require.NoError(t, err) + } + for path, expect := range tt.expected { + b, err := afero.ReadFile(fs, path) + require.NoError(t, err) + require.Equal(t, expect, string(b)) + } + }) + } +} diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 46c296c0535aa..12ab40e714f0f 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -44,6 +44,8 @@ const ( ToolNameChatGPTFetch = "fetch" 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) { @@ -213,6 +215,8 @@ var All = []GenericTool{ ChatGPTFetch.Generic(), WorkspaceReadFile.Generic(), WorkspaceWriteFile.Generic(), + WorkspaceEditFile.Generic(), + WorkspaceEditFiles.Generic(), } type ReportTaskArgs struct { @@ -1491,6 +1495,150 @@ 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.EditFiles(ctx, workspacesdk.FileEditRequest{ + Files: []workspacesdk.FileEdits{ + { + Path: args.Path, + Edits: args.Edits, + }, + }, + }) + if err != nil { + return codersdk.Response{}, err + } + + return codersdk.Response{ + Message: "File edited successfully.", + }, nil + }, +} + +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 0030549f5eea2..db0d65c02c0ee 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,107 @@ 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)) + }) + + 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. diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 0afb6f0c868a8..4fc6bc15266fb 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 + 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) @@ -523,15 +525,70 @@ 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 FileEdits struct { + Path string `json:"path"` + Edits []FileEdit `json:"edits"` +} + +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, "/api/v0/edit-files", 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..cf7050de0cd4e 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) } +// EditFiles mocks base method. +func (m *MockAgentConn) EditFiles(ctx context.Context, edits workspacesdk.FileEditRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EditFiles", ctx, edits) + ret0, _ := ret[0].(error) + return ret0 +} + +// 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, "EditFiles", reflect.TypeOf((*MockAgentConn)(nil).EditFiles), ctx, 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=