diff --git a/internal/acp/handlers/handlers.go b/internal/acp/handlers/handlers.go index 2ad8a80..3851160 100644 --- a/internal/acp/handlers/handlers.go +++ b/internal/acp/handlers/handlers.go @@ -38,9 +38,16 @@ type FsError struct { Message string } -// FsHandler is the placeholder for fs/read_text_file + fs/write_text_file. -// Fields and methods are added in Task D4. -type FsHandler struct{} +// FsHandler combines fs/* method handlers so Relay can hold one reference. +type FsHandler struct { + Read *FsReadHandler + Write *FsWriteHandler +} + +// NewFsHandler constructs a composite FsHandler with default sub-handlers. +func NewFsHandler() *FsHandler { + return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()} +} // PermissionHandler is the placeholder for session/request_permission. // Fields and methods are added in Task D5. diff --git a/internal/acp/handlers/server_fs_read.go b/internal/acp/handlers/server_fs_read.go new file mode 100644 index 0000000..a5de71d --- /dev/null +++ b/internal/acp/handlers/server_fs_read.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// FsReadHandler handles fs/read_text_file (spec §5.4). +type FsReadHandler struct{} + +// NewFsReadHandler constructs an FsReadHandler. +func NewFsReadHandler() *FsReadHandler { return &FsReadHandler{} } + +// fsReadParams is the ACP-protocol input for fs/read_text_file. +type fsReadParams struct { + SessionID string `json:"sessionId"` + Path string `json:"path"` + Line *int `json:"line,omitempty"` + Limit *int `json:"limit,omitempty"` +} + +type fsReadResult struct { + Content string `json:"content"` +} + +// Handle reads a file inside sess.CwdPath, returning content (with optional +// line/limit slicing). Returns FsError for path-escape or read failure. +func (h *FsReadHandler) Handle(_ context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult { + var p fsReadParams + if err := json.Unmarshal(paramsJSON, &p); err != nil { + return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}} + } + abs, err := resolveSafePath(sess.CwdPath, p.Path) + if err != nil { + return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}} + } + data, err := os.ReadFile(abs) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return FsResult{Err: &FsError{Code: -32603, Message: "file not found"}} + } + return FsResult{Err: &FsError{Code: -32603, Message: "read: " + err.Error()}} + } + content := string(data) + if p.Line != nil || p.Limit != nil { + content = sliceLines(content, p.Line, p.Limit) + } + out, _ := json.Marshal(fsReadResult{Content: content}) + return FsResult{OK: out} +} + +// sliceLines returns lines[line:line+limit] joined by "\n". +// line is 0-based; limit caps the line count. +func sliceLines(s string, line, limit *int) string { + lines := strings.Split(s, "\n") + start := 0 + if line != nil && *line > 0 { + start = *line + } + if start > len(lines) { + start = len(lines) + } + end := len(lines) + if limit != nil && *limit >= 0 { + if start+*limit < end { + end = start + *limit + } + } + return strings.Join(lines[start:end], "\n") +} + +// resolveSafePath duplicates the logic from internal/acp.ResolveSafePath to +// avoid an import cycle (handlers is a sub-package and acp imports handlers). +func resolveSafePath(cwd, requested string) (string, error) { + if !filepath.IsAbs(cwd) { + return "", fmt.Errorf("cwd must be absolute") + } + cleanCwd := filepath.Clean(cwd) + var abs string + if filepath.IsAbs(requested) { + abs = filepath.Clean(requested) + } else { + abs = filepath.Clean(filepath.Join(cleanCwd, requested)) + } + if !hasPathPrefix(abs, cleanCwd) { + return "", fmt.Errorf("path outside worktree") + } + if real, err := filepath.EvalSymlinks(abs); err == nil { + if !hasPathPrefix(real, cleanCwd) { + return "", fmt.Errorf("path resolves outside worktree via symlink") + } + } + return abs, nil +} + +func hasPathPrefix(abs, base string) bool { + if abs == base { + return true + } + sep := string(filepath.Separator) + return strings.HasPrefix(abs, base+sep) +} diff --git a/internal/acp/handlers/server_fs_read_test.go b/internal/acp/handlers/server_fs_read_test.go new file mode 100644 index 0000000..4498fd2 --- /dev/null +++ b/internal/acp/handlers/server_fs_read_test.go @@ -0,0 +1,89 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" +) + +func TestFsRead_HappyPath(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("hello\n"), 0o644)) + + h := handlers.NewFsReadHandler() + sess := handlers.SessionContext{SessionID: uuid.New(), CwdPath: tmp} + params := mustJSON(t, map[string]any{"sessionId": "x", "path": filepath.Join(tmp, "a.txt")}) + + res := h.Handle(context.Background(), sess, params) + require.Nil(t, res.Err) + require.NotNil(t, res.OK) + var out struct { + Content string `json:"content"` + } + require.NoError(t, json.Unmarshal(res.OK, &out)) + assert.Equal(t, "hello\n", out.Content) +} + +func TestFsRead_PathOutsideWorktree(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + h := handlers.NewFsReadHandler() + sess := handlers.SessionContext{CwdPath: tmp} + outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc", "passwd") + params := mustJSON(t, map[string]any{"sessionId": "x", "path": outside}) + + res := h.Handle(context.Background(), sess, params) + require.NotNil(t, res.Err) + assert.Equal(t, -32001, res.Err.Code) +} + +func TestFsRead_FileNotFound(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + h := handlers.NewFsReadHandler() + sess := handlers.SessionContext{CwdPath: tmp} + params := mustJSON(t, map[string]any{"sessionId": "x", "path": filepath.Join(tmp, "nope.txt")}) + + res := h.Handle(context.Background(), sess, params) + require.NotNil(t, res.Err) + assert.Equal(t, -32603, res.Err.Code) +} + +func TestFsRead_LineLimit(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "f.txt"), + []byte("L0\nL1\nL2\nL3\n"), 0o644)) + + h := handlers.NewFsReadHandler() + sess := handlers.SessionContext{CwdPath: tmp} + line := 1 + limit := 2 + params := mustJSON(t, map[string]any{ + "sessionId": "x", "path": filepath.Join(tmp, "f.txt"), + "line": line, "limit": limit, + }) + res := h.Handle(context.Background(), sess, params) + require.Nil(t, res.Err) + var out struct { + Content string `json:"content"` + } + _ = json.Unmarshal(res.OK, &out) + assert.Equal(t, "L1\nL2", out.Content) +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return b +} diff --git a/internal/acp/handlers/server_fs_write.go b/internal/acp/handlers/server_fs_write.go new file mode 100644 index 0000000..0cf2e42 --- /dev/null +++ b/internal/acp/handlers/server_fs_write.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "context" + "encoding/json" + "os" + "path/filepath" +) + +// FsWriteHandler handles fs/write_text_file (spec §5.4). +type FsWriteHandler struct{} + +// NewFsWriteHandler constructs an FsWriteHandler. +func NewFsWriteHandler() *FsWriteHandler { return &FsWriteHandler{} } + +type fsWriteParams struct { + SessionID string `json:"sessionId"` + Path string `json:"path"` + Content string `json:"content"` +} + +// Handle writes a file inside sess.CwdPath, creating parent dirs as needed. +// Returns FsError for path-escape or filesystem failure. +func (h *FsWriteHandler) Handle(_ context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult { + var p fsWriteParams + if err := json.Unmarshal(paramsJSON, &p); err != nil { + return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}} + } + abs, err := resolveSafePath(sess.CwdPath, p.Path) + if err != nil { + return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}} + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return FsResult{Err: &FsError{Code: -32603, Message: "mkdir: " + err.Error()}} + } + if err := os.WriteFile(abs, []byte(p.Content), 0o644); err != nil { + return FsResult{Err: &FsError{Code: -32603, Message: "write: " + err.Error()}} + } + return FsResult{OK: json.RawMessage(`{}`)} +} diff --git a/internal/acp/handlers/server_fs_write_test.go b/internal/acp/handlers/server_fs_write_test.go new file mode 100644 index 0000000..8d3668d --- /dev/null +++ b/internal/acp/handlers/server_fs_write_test.go @@ -0,0 +1,47 @@ +package handlers_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" +) + +func TestFsWrite_HappyPath(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + h := handlers.NewFsWriteHandler() + sess := handlers.SessionContext{SessionID: uuid.New(), CwdPath: tmp} + + target := filepath.Join(tmp, "newdir", "x.txt") + params := mustJSON(t, map[string]any{ + "sessionId": "x", "path": target, "content": "hello", + }) + res := h.Handle(context.Background(), sess, params) + require.Nil(t, res.Err) + + got, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "hello", string(got)) +} + +func TestFsWrite_OutsideWorktree(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + h := handlers.NewFsWriteHandler() + sess := handlers.SessionContext{CwdPath: tmp} + + outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "tmp", "escape.txt") + params := mustJSON(t, map[string]any{ + "sessionId": "x", "path": outside, "content": "evil", + }) + res := h.Handle(context.Background(), sess, params) + require.NotNil(t, res.Err) + assert.Equal(t, -32001, res.Err.Code) +}