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
@@ -6,6 +6,7 @@ import (
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/readselector"
)
// Tool operation type constants.
@@ -313,9 +314,24 @@ func updateModifyFileInput(mf *streams.ModifyFilePayload, supplemental, inputMap
}
}
// updateReadFileInput fills a ReadFile payload from a tool_call_update: it sets
// FilePath (when still empty) to the selector-stripped path and populates
// Offset/Limit from the parsed line range when those fields are unset.
func updateReadFileInput(rf *streams.ReadFilePayload, supplemental, inputMap map[string]any) {
if path := pathFromArgs(supplemental, inputMap); path != "" && rf.FilePath == "" {
rf.FilePath = path
// Parse the selector on every update — a later tool_call_update can carry the
// line range (e.g. "main.go:50+150") even when an earlier frame already set
// FilePath, so range metadata must not be gated on FilePath being empty.
if path := pathFromArgs(supplemental, inputMap); path != "" {
clean, startLine, lineCount := readselector.Split(path)
if rf.FilePath == "" {
rf.FilePath = clean
}
if rf.Offset == 0 {
rf.Offset = startLine
}
if rf.Limit == 0 {
rf.Limit = lineCount
}
}
}
@@ -383,13 +399,11 @@ func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPaylo
Type: streams.MutationPatch,
}
// Add line numbers if available
if startLine, ok := rawInput["old_str_start_line_number_1"].(float64); ok {
mutation.StartLine = int(startLine)
}
if endLine, ok := rawInput["old_str_end_line_number_1"].(float64); ok {
mutation.EndLine = int(endLine)
}
// Line numbers vary by agent: Claude's str-replace editor uses
// old_str_*_line_number_1, OMP uses startLine/endLine. Accept both
// (plus snake_case) so edit links can navigate to the changed lines.
mutation.StartLine = firstInt(rawInput, "old_str_start_line_number_1", "startLine", "start_line")
mutation.EndLine = firstInt(rawInput, "old_str_end_line_number_1", "endLine", "end_line")
// Generate unified diff when at least one string is provided
if oldStr != "" || newStr != "" {
@@ -403,6 +417,18 @@ func (n *Normalizer) normalizeEdit(args map[string]any) *streams.NormalizedPaylo
return streams.NewModifyFile(path, mutations)
}
// firstInt returns the first key whose value is a non-zero int, handling JSON
// numbers (decoded as float64). Line/column numbers are 1-based, so a missing
// or zero value is treated as absent.
func firstInt(m map[string]any, keys ...string) int {
for _, k := range keys {
if v := shared.GetInt(m, k); v != 0 {
return v
}
}
return 0
}
// normalizeRead converts ACP read tool data.
// If rawInput.type is "directory", this becomes a code search (file listing) operation.
func (n *Normalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload {
@@ -412,13 +438,17 @@ func (n *Normalizer) normalizeRead(args map[string]any) *streams.NormalizedPaylo
}
path := pathFromArgs(args, rawInput)
// OMP's read tool embeds a line/range/mode selector in the path
// (e.g. "foo.go:43-94"); strip it so the file link stays openable and
// carry the parsed range via offset/limit. No-op for other agents.
path, startLine, lineCount := readselector.Split(path)
// Check if this is a directory read - treat as code search (file listing)
if readType := shared.GetString(rawInput, "type"); readType == readTypeDirectory {
return streams.NewCodeSearch("", "", path, "")
}
return streams.NewReadFile(path, 0, 0)
return streams.NewReadFile(path, startLine, lineCount)
}
// normalizeExecute converts ACP execute/bash tool data.
@@ -393,6 +393,40 @@ func TestNormalizerEdit(t *testing.T) {
t.Errorf("expected FilePath 'src/main.go', got %q", result.ModifyFile().FilePath)
}
})
t.Run("extracts omp startLine/endLine into mutation", func(t *testing.T) {
result := normalizer.NormalizeToolCall("edit", map[string]any{
"kind": "edit",
"raw_input": map[string]any{
"path": "main.go",
"old_str_1": "a",
"new_str_1": "b",
"startLine": float64(43),
"endLine": float64(94),
},
})
m := result.ModifyFile().Mutations[0]
if m.StartLine != 43 || m.EndLine != 94 {
t.Errorf("expected StartLine=43 EndLine=94, got %d/%d", m.StartLine, m.EndLine)
}
})
t.Run("still extracts claude old_str line numbers", func(t *testing.T) {
result := normalizer.NormalizeToolCall("edit", map[string]any{
"kind": "edit",
"raw_input": map[string]any{
"path": "main.go",
"old_str_1": "a",
"new_str_1": "b",
"old_str_start_line_number_1": float64(10),
"old_str_end_line_number_1": float64(12),
},
})
m := result.ModifyFile().Mutations[0]
if m.StartLine != 10 || m.EndLine != 12 {
t.Errorf("expected StartLine=10 EndLine=12, got %d/%d", m.StartLine, m.EndLine)
}
})
}
// TestNormalizerRead tests the normalizer's read handling.
@@ -453,6 +487,59 @@ func TestNormalizerRead(t *testing.T) {
t.Errorf("expected Path '.', got %q", result.CodeSearch().Path)
}
})
t.Run("strips omp line selector from path and records range", func(t *testing.T) {
args := map[string]any{
"kind": "read",
"raw_input": map[string]any{
"path": "apps/backend/internal/sentry/handlers.go:43-94",
},
}
result := normalizer.NormalizeToolCall("read", args)
rf := result.ReadFile()
if rf == nil {
t.Fatal("expected ReadFile to be set")
}
if rf.FilePath != "apps/backend/internal/sentry/handlers.go" {
t.Errorf("expected stripped FilePath, got %q", rf.FilePath)
}
if rf.Offset != 43 || rf.Limit != 52 {
t.Errorf("expected Offset=43 Limit=52, got Offset=%d Limit=%d", rf.Offset, rf.Limit)
}
})
t.Run("strips omp selector arriving on a tool_call_update", func(t *testing.T) {
payload := normalizer.NormalizeToolCall("read", map[string]any{"kind": "read"})
normalizer.UpdatePayloadInput(payload, map[string]any{"path": "main.go:50+150"}, nil)
rf := payload.ReadFile()
if rf == nil {
t.Fatal("expected ReadFile to be set")
}
if rf.FilePath != "main.go" {
t.Errorf("expected stripped FilePath 'main.go', got %q", rf.FilePath)
}
if rf.Offset != 50 || rf.Limit != 150 {
t.Errorf("expected Offset=50 Limit=150, got Offset=%d Limit=%d", rf.Offset, rf.Limit)
}
})
t.Run("populates range from update even when FilePath already set", func(t *testing.T) {
payload := normalizer.NormalizeToolCall("read", map[string]any{
"kind": "read",
"raw_input": map[string]any{"path": "main.go"},
})
if payload.ReadFile().FilePath != "main.go" {
t.Fatalf("expected initial FilePath 'main.go', got %q", payload.ReadFile().FilePath)
}
normalizer.UpdatePayloadInput(payload, map[string]any{"path": "main.go:50+150"}, nil)
rf := payload.ReadFile()
if rf.FilePath != "main.go" {
t.Errorf("expected FilePath unchanged 'main.go', got %q", rf.FilePath)
}
if rf.Offset != 50 || rf.Limit != 150 {
t.Errorf("expected Offset=50 Limit=150 from update, got Offset=%d Limit=%d", rf.Offset, rf.Limit)
}
})
}
// TestNormalizerResult tests the normalizer's result handling.
@@ -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()
@@ -0,0 +1,122 @@
// Package readselector parses Oh-My-Pi (omp) style read paths of the form
// "path:<selector>" into the bare file path plus the parsed line range.
//
// OMP's read tool embeds a line/range/mode selector in the path argument
// (e.g. "foo.go:43-94", "foo.go:50+150", "foo.go:5-16,960-973", "foo.go:raw",
// "foo.go:2-4:raw"). Kandev opens files by stat'ing the path, so a selector
// left on the path makes the open fail ("Failed to open file … no such file"
// for workspace files, or "path traversal detected" for absolute external
// files whose stat'd "<path>:<range>" simply doesn't exist).
//
// Both the ACP normalizer (so new file links are stored clean and the line
// range is surfaced separately) and the file-open boundary (so already-persisted
// links and any normalization gap still open) call Split — a single source of
// truth shared by both.
package readselector
import (
"strconv"
"strings"
)
// Split splits "path:<selector>" into the bare file path plus the parsed line
// range. startLine is the first line referenced by the selector; lineCount is
// the contiguous span when a closed range is given ("N-M" or "N+K"), and 0 when
// the selector is open-ended ("N", "N-") or carries no line numbers ("raw").
// When no valid selector is present the path is returned unchanged with zero
// line info, so paths from non-omp agents (which never match the grammar) and
// ordinary filenames are unaffected.
func Split(raw string) (path string, startLine, lineCount int) {
// Only inspect colons in the final path segment so a directory component
// that legitimately contains a colon is never mistaken for a selector.
// Consider both '/' and '\\' so Windows paths — and their drive-letter
// colon, e.g. "C:\\dir\\file.go" — are not misread as a selector boundary.
lastSep := strings.LastIndexByte(raw, '/')
if b := strings.LastIndexByte(raw, '\\'); b > lastSep {
lastSep = b
}
rel := strings.IndexByte(raw[lastSep+1:], ':')
if rel < 0 {
return raw, 0, 0
}
colon := lastSep + 1 + rel
base, suffix := raw[:colon], raw[colon+1:]
if base == "" || suffix == "" {
return raw, 0, 0
}
start, count, ok := parseReadSelector(suffix)
if !ok {
return raw, 0, 0
}
return base, start, count
}
// parseReadSelector validates the full selector tail (everything after the
// first ':' in the final segment). Selector parts are joined by ':' so combos
// such as "2-4:raw" and "raw:2-4" are accepted; each part must be a line-spec
// list or a recognized mode keyword. The first line-spec encountered drives the
// returned startLine/lineCount.
func parseReadSelector(suffix string) (startLine, lineCount int, ok bool) {
gotLine := false
for _, part := range strings.Split(suffix, ":") {
if part == "raw" || part == "conflicts" {
continue
}
s, c, valid := parseLineSpecList(part)
if !valid {
return 0, 0, false
}
if !gotLine {
startLine, lineCount, gotLine = s, c, true
}
}
return startLine, lineCount, true
}
// parseLineSpecList parses a comma-separated list of line specs ("5-16,960-973")
// and reports the start/count of the first spec.
func parseLineSpecList(part string) (startLine, lineCount int, ok bool) {
segs := strings.Split(part, ",")
for i, seg := range segs {
s, c, valid := parseLineSpec(seg)
if !valid {
return 0, 0, false
}
if i == 0 {
startLine, lineCount = s, c
}
}
return startLine, lineCount, true
}
// parseLineSpec parses a single line spec: "N", "N-", "N-M", or "N+K".
func parseLineSpec(seg string) (startLine, lineCount int, ok bool) {
if i := strings.IndexByte(seg, '+'); i >= 0 {
start, errA := strconv.Atoi(seg[:i])
count, errB := strconv.Atoi(seg[i+1:])
if errA != nil || errB != nil || start <= 0 || count < 0 {
return 0, 0, false
}
return start, count, true
}
if i := strings.IndexByte(seg, '-'); i >= 0 {
start, errA := strconv.Atoi(seg[:i])
if errA != nil || start <= 0 {
return 0, 0, false
}
rest := seg[i+1:]
if rest == "" { // "N-" — open-ended
return start, 0, true
}
end, errB := strconv.Atoi(rest)
if errB != nil || end < start {
return 0, 0, false
}
return start, end - start + 1, true
}
start, err := strconv.Atoi(seg)
if err != nil || start <= 0 {
return 0, 0, false
}
return start, 0, true
}
@@ -0,0 +1,81 @@
package readselector
import "testing"
func TestSplit(t *testing.T) {
tests := []struct {
name string
raw string
wantPath string
wantStart int
wantCount int
}{
{"no selector", "config.json", "config.json", 0, 0},
{"single line", "apps/web/lib/utils.ts:50", "apps/web/lib/utils.ts", 50, 0},
{"open ended", "main.go:50-", "main.go", 50, 0},
{"closed range", "apps/backend/internal/sentry/handlers.go:43-94", "apps/backend/internal/sentry/handlers.go", 43, 52},
{"plus span", "main.go:50+150", "main.go", 50, 150},
{"anchor", "main.go:20+1", "main.go", 20, 1},
{"multi range", "main.go:5-16,960-973", "main.go", 5, 12},
{"raw mode", "main.go:raw", "main.go", 0, 0},
{"conflicts mode", "main.go:conflicts", "main.go", 0, 0},
{"range then raw", "main.go:2-4:raw", "main.go", 2, 3},
{"raw then range", "main.go:raw:2-4", "main.go", 2, 3},
{"absolute path", "/home/u/.kandev/x/handlers.go:43-94", "/home/u/.kandev/x/handlers.go", 43, 52},
{"absolute external", "/home/clem/.claude/CLAUDE.md:93-113", "/home/clem/.claude/CLAUDE.md", 93, 21},
{"no extension single line", "Makefile:10", "Makefile", 10, 0},
// Real OMP reads observed in the wild (multi-range, zero-length range,
// range+mode combo, and a "~"-prefixed home path).
{"omp multi range zero-len", "x/README.md:16-20,32-40,69-69,85-96", "x/README.md", 16, 5},
{"omp range plus raw combo", "x/README.md:69+1:raw", "x/README.md", 69, 1},
{"omp tilde multi range", "~/.kandev/x/README.md:27-28,35-36,66-66", "~/.kandev/x/README.md", 27, 2},
// Windows path: drive-letter colon must not be read as the selector
// boundary; the trailing "\\" segment + ":43-94" selector is stripped.
{"windows absolute", `C:\Users\me\handlers.go:43-94`, `C:\Users\me\handlers.go`, 43, 52},
// "N+0" is intentionally accepted (count 0, anchor at N) and stripped, so
// the link still opens; rejecting it would leave the selector on the path.
{"plus zero anchors", "main.go:50+0", "main.go", 50, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path, start, count := Split(tt.raw)
if path != tt.wantPath {
t.Errorf("path = %q, want %q", path, tt.wantPath)
}
if start != tt.wantStart {
t.Errorf("startLine = %d, want %d", start, tt.wantStart)
}
if count != tt.wantCount {
t.Errorf("lineCount = %d, want %d", count, tt.wantCount)
}
})
}
}
// TestSplit_NoFalsePositives verifies that paths whose colon suffix is not a
// valid omp selector are returned untouched, so legitimate filenames and
// non-omp agent paths never lose data.
func TestSplit_NoFalsePositives(t *testing.T) {
unchanged := []string{
"",
"src/file.ts",
"foo.go:bar", // non-numeric, non-keyword suffix
"foo.go:1.2", // float-like, not a line spec
`C:\Users\me\a.go`, // windows drive letter: suffix is not a line spec
"dir:1/file.go", // colon lives in a directory component, not the file
":43-94", // empty base
"main.go:", // empty selector
"main.go:-5", // negative / malformed start
"main.go:0", // zero is not a valid 1-based line
"main.go:10-5", // descending range
"main.go:5,abc", // one invalid segment invalidates the list
}
for _, raw := range unchanged {
t.Run(raw, func(t *testing.T) {
path, start, count := Split(raw)
if path != raw || start != 0 || count != 0 {
t.Errorf("Split(%q) = (%q, %d, %d), want (%q, 0, 0)", raw, path, start, count, raw)
}
})
}
}