fix: open agent file links that carry line-range selectors (#1412)
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/common/readselector"
|
||||
"github.com/kandev/kandev/internal/common/subproc"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -279,6 +280,37 @@ func resolveNonExistentPath(path string) (string, error) {
|
||||
// If the file is not valid UTF-8, it is base64-encoded and isBinary is true.
|
||||
// If the file is a symlink, resolvedPath contains the target path relative to the workspace root.
|
||||
func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool, string, error) {
|
||||
// Try the path exactly as requested first, so a real workspace file whose
|
||||
// name contains a colon (e.g. "notes.txt:2-3") or a literal "~" path
|
||||
// segment still opens. Only if the literal open fails do we treat a
|
||||
// trailing piece as an omp read selector (e.g. "foo.go:43-94", multi-range)
|
||||
// and/or a "~"-home shorthand, strip/expand it, and retry — so agent links
|
||||
// that embed a selector open without breaking valid filenames.
|
||||
content, size, isBinary, resolved, err := wt.readResolvedPath(reqPath)
|
||||
if err == nil {
|
||||
return content, size, isBinary, resolved, nil
|
||||
}
|
||||
if alt := expandHomePath(stripReadSelector(reqPath)); alt != reqPath {
|
||||
if c, s, b, r, altErr := wt.readResolvedPath(alt); altErr == nil {
|
||||
return c, s, b, r, nil
|
||||
}
|
||||
}
|
||||
// Neither the literal path nor the stripped/expanded fallback opened;
|
||||
// surface the original (literal-path) error, which is the most meaningful.
|
||||
return content, size, isBinary, resolved, err
|
||||
}
|
||||
|
||||
// stripReadSelector removes a trailing omp read selector from reqPath (the line
|
||||
// range itself is irrelevant to serving content); paths without a selector are
|
||||
// returned unchanged.
|
||||
func stripReadSelector(reqPath string) string {
|
||||
clean, _, _ := readselector.Split(reqPath)
|
||||
return clean
|
||||
}
|
||||
|
||||
// readResolvedPath resolves reqPath within the workspace, falling back to the
|
||||
// read-only external-absolute-path path (ADR 0016), and returns its content.
|
||||
func (wt *WorkspaceTracker) readResolvedPath(reqPath string) (string, int64, bool, string, error) {
|
||||
safePath, err := wt.resolveSafePath(reqPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, errPathTraversal) {
|
||||
@@ -299,6 +331,24 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool,
|
||||
return content, size, isBinary, resolvedPath, err
|
||||
}
|
||||
|
||||
// expandHomePath expands a leading "~" or "~/" to the current user's home
|
||||
// directory. omp emits read paths like "~/.kandev/…"; a literal tilde is not a
|
||||
// real filesystem path, so it must be expanded before stat/serve. Other paths
|
||||
// (absolute, workspace-relative) are returned unchanged.
|
||||
func expandHomePath(path string) string {
|
||||
if path != "~" && !strings.HasPrefix(path, "~/") && !strings.HasPrefix(path, `~\`) {
|
||||
return path
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return path
|
||||
}
|
||||
if path == "~" {
|
||||
return home
|
||||
}
|
||||
return filepath.Join(home, path[2:])
|
||||
}
|
||||
|
||||
func readFileContent(safePath string) (string, int64, bool, error) {
|
||||
// Check if file exists and is a regular file
|
||||
info, err := os.Stat(safePath)
|
||||
|
||||
@@ -556,6 +556,119 @@ func TestGetFileContent_ExternalAbsolutePath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFileContent_StripsReadSelector covers the file-open boundary fix: a
|
||||
// path carrying an omp read-selector (e.g. "<file>:43-94") must still open,
|
||||
// both for an in-workspace file and for an external absolute file (the
|
||||
// CLAUDE.md:93-113 "path traversal detected" repro).
|
||||
func TestGetFileContent_StripsReadSelector(t *testing.T) {
|
||||
workDir, wt := setupTestDir(t)
|
||||
|
||||
inside := filepath.Join(workDir, "notes.txt")
|
||||
insideContent := "l1\nl2\nl3\nl4\n"
|
||||
if err := os.WriteFile(inside, []byte(insideContent), 0o644); err != nil {
|
||||
t.Fatalf("write in-workspace file: %v", err)
|
||||
}
|
||||
content, _, _, _, err := wt.GetFileContent("notes.txt:2-3")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFileContent(in-workspace + selector) error: %v", err)
|
||||
}
|
||||
if content != insideContent {
|
||||
t.Fatalf("content = %q, want full file %q", content, insideContent)
|
||||
}
|
||||
|
||||
externalDir := t.TempDir()
|
||||
externalPath := filepath.Join(externalDir, "CLAUDE.md")
|
||||
externalContent := "global config\n"
|
||||
if err := os.WriteFile(externalPath, []byte(externalContent), 0o644); err != nil {
|
||||
t.Fatalf("write external file: %v", err)
|
||||
}
|
||||
content, _, _, _, err = wt.GetFileContent(externalPath + ":93-113")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFileContent(external absolute + selector) error: %v", err)
|
||||
}
|
||||
if content != externalContent {
|
||||
t.Fatalf("content = %q, want %q", content, externalContent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFileContent_ExpandsTildeWithMultiRange covers OMP reads that arrive as
|
||||
// a "~"-prefixed home path with a multi-range selector
|
||||
// (e.g. "~/.kandev/x/README.md:16-20,32-40,69-69,85-96"): the tilde must be
|
||||
// expanded and the whole multi-range selector stripped for the open to succeed.
|
||||
func TestGetFileContent_ExpandsTildeWithMultiRange(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home) // os.UserHomeDir() on unix/macOS
|
||||
t.Setenv("USERPROFILE", home) // os.UserHomeDir() on Windows
|
||||
sub := filepath.Join(home, "notes")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
want := "global config\n"
|
||||
if err := os.WriteFile(filepath.Join(sub, "GLOBAL.md"), []byte(want), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_, wt := setupTestDir(t)
|
||||
got, _, _, _, err := wt.GetFileContent("~/notes/GLOBAL.md:16-20,32-40,69-69,85-96")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFileContent(tilde + multi-range) error: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("content = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFileContent_PrefersLiteralColonPath verifies a real workspace file
|
||||
// whose name literally contains a colon (e.g. "notes.txt:2-3") opens as-is and
|
||||
// is NOT mistaken for a "notes.txt" + read selector.
|
||||
func TestGetFileContent_PrefersLiteralColonPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("colon is not a legal filename character on Windows")
|
||||
}
|
||||
workDir, wt := setupTestDir(t)
|
||||
if err := os.WriteFile(filepath.Join(workDir, "notes.txt"), []byte("plain\n"), 0o644); err != nil {
|
||||
t.Fatalf("write notes.txt: %v", err)
|
||||
}
|
||||
literal := "literal-colon-file\n"
|
||||
if err := os.WriteFile(filepath.Join(workDir, "notes.txt:2-3"), []byte(literal), 0o644); err != nil {
|
||||
t.Fatalf("write literal: %v", err)
|
||||
}
|
||||
got, _, _, _, err := wt.GetFileContent("notes.txt:2-3")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFileContent error: %v", err)
|
||||
}
|
||||
if got != literal {
|
||||
t.Fatalf("expected literal colon file %q, got %q (selector wrongly stripped)", literal, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFileContent_PrefersLiteralTildePath verifies a workspace file under a
|
||||
// literal "~" directory opens as-is and is NOT expanded to the user's home.
|
||||
func TestGetFileContent_PrefersLiteralTildePath(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
// Decoy at the home-expanded location to prove expansion is not used.
|
||||
if err := os.WriteFile(filepath.Join(home, "config.txt"), []byte("home-decoy\n"), 0o644); err != nil {
|
||||
t.Fatalf("write decoy: %v", err)
|
||||
}
|
||||
workDir, wt := setupTestDir(t)
|
||||
tildeDir := filepath.Join(workDir, "~")
|
||||
if err := os.MkdirAll(tildeDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir ~: %v", err)
|
||||
}
|
||||
want := "workspace-tilde\n"
|
||||
if err := os.WriteFile(filepath.Join(tildeDir, "config.txt"), []byte(want), 0o644); err != nil {
|
||||
t.Fatalf("write tilde file: %v", err)
|
||||
}
|
||||
got, _, _, _, err := wt.GetFileContent("~/config.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFileContent error: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("expected workspace tilde file %q, got %q (path wrongly home-expanded)", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestDir creates a temp directory with a WorkspaceTracker (no git required).
|
||||
func setupTestDir(t *testing.T) (string, *WorkspaceTracker) {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user