fix: open agent file links that carry line-range selectors (#1412)

This commit is contained in:
Clément Fiere
2026-06-17 21:32:40 +02:00
committed by GitHub
parent 67037fd936
commit a71ee2e2c1
14 changed files with 798 additions and 52 deletions
@@ -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()