Skip to content

Commit 4eee71a

Browse files
fix(parser): prune qwenpaw source traversal
QwenPaw legacy discovery only walks valid workspace directories, the sessions directory, and one real non-hidden namespace below sessions. The provider migration reused the generic recursive JSONL walker, which preserved emitted source filtering but still allowed traversal and event classification through deeper or symlinked session namespaces. Add a shared traversal predicate to the JSONL source helper so providers can keep discovery and changed-path classification aligned when their source layouts are narrower than an unbounded recursive scan.
1 parent 9a8da87 commit 4eee71a

4 files changed

Lines changed: 149 additions & 1 deletion

File tree

internal/parser/jsonl_source_set.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ type JSONLSourceSetOptions struct {
4040
// should enable it when legacy discovery accepted matching symlinked files
4141
// and the parser reads through the symlink target.
4242
FollowSymlinkFiles bool
43+
// DescendPath is a directory predicate for recursive discovery. It is also
44+
// applied to source ancestors during direct source classification so
45+
// changed-path events cannot accept paths discovery would have pruned.
46+
DescendPath func(root, path string) bool
4347
// IncludePath is a path-only source predicate. It runs before Include and is
4448
// also used for deleted/renamed changed paths where os.FileInfo is
4549
// unavailable.
@@ -253,7 +257,7 @@ func (s JSONLSourceSet) discoverDir(
253257
}
254258
path := filepath.Join(dir, entry.Name())
255259
if s.shouldDescend(entry, dir) {
256-
if s.options.Recursive {
260+
if s.options.Recursive && s.descendPathIncluded(root, path) {
257261
if err := s.discoverDir(
258262
ctx, root, path, sources, seen,
259263
); err != nil {
@@ -292,6 +296,9 @@ func (s JSONLSourceSet) sourceForPath(path string) (SourceRef, bool) {
292296
if !s.pathAllowedByRoot(root, path) {
293297
continue
294298
}
299+
if !s.sourcePathAllowedByDescendPath(root, path) {
300+
continue
301+
}
295302
if !s.pathIncluded(root, path) {
296303
continue
297304
}
@@ -324,6 +331,9 @@ func (s JSONLSourceSet) sourceForMissingPath(path string) (SourceRef, bool) {
324331
if !s.pathAllowedByRoot(root, path) {
325332
continue
326333
}
334+
if !s.sourcePathAllowedByDescendPath(root, path) {
335+
continue
336+
}
327337
if !s.matchesExtension(path) || !s.pathIncluded(root, path) {
328338
continue
329339
}
@@ -411,6 +421,35 @@ func (s JSONLSourceSet) pathIncluded(root, path string) bool {
411421
return s.options.IncludePath == nil || s.options.IncludePath(root, path)
412422
}
413423

424+
func (s JSONLSourceSet) descendPathIncluded(root, path string) bool {
425+
return s.options.DescendPath == nil || s.options.DescendPath(root, path)
426+
}
427+
428+
func (s JSONLSourceSet) sourcePathAllowedByDescendPath(root, path string) bool {
429+
if s.options.DescendPath == nil {
430+
return true
431+
}
432+
rel, err := filepath.Rel(root, path)
433+
if err != nil {
434+
return false
435+
}
436+
dir := filepath.Dir(rel)
437+
if dir == "." {
438+
return true
439+
}
440+
current := root
441+
for part := range strings.SplitSeq(dir, string(filepath.Separator)) {
442+
if part == "" || part == "." || part == ".." {
443+
return false
444+
}
445+
current = filepath.Join(current, part)
446+
if !s.descendPathIncluded(root, current) {
447+
return false
448+
}
449+
}
450+
return true
451+
}
452+
414453
func (s JSONLSourceSet) matchesExtension(path string) bool {
415454
ext := filepath.Ext(path)
416455
return slices.Contains(s.extensions, ext)

internal/parser/jsonl_source_set_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,44 @@ func TestJSONLSourceSetChangedPathUsesPathOnlyFilterForDeletedFiles(t *testing.T
266266
assert.Equal(t, filepath.Join(root, "session", "events.jsonl"), changed[0].DisplayPath)
267267
}
268268

269+
func TestJSONLSourceSetDescendPathPrunesSources(t *testing.T) {
270+
root := t.TempDir()
271+
keepPath := filepath.Join(root, "keep", "session.jsonl")
272+
skipPath := filepath.Join(root, "skip", "session.jsonl")
273+
writeSourceFile(t, keepPath, "{}\n")
274+
writeSourceFile(t, skipPath, "{}\n")
275+
276+
sources := NewJSONLSourceSet(AgentCodex, []string{root}, JSONLSourceSetOptions{
277+
Recursive: true,
278+
DescendPath: func(root, path string) bool {
279+
return filepath.Base(path) != "skip"
280+
},
281+
})
282+
283+
discovered, err := sources.Discover(context.Background())
284+
require.NoError(t, err)
285+
require.Len(t, discovered, 1)
286+
assert.Equal(t, keepPath, discovered[0].DisplayPath)
287+
288+
changed, err := sources.SourcesForChangedPath(
289+
context.Background(),
290+
ChangedPathRequest{Path: skipPath, EventKind: "write", WatchRoot: root},
291+
)
292+
require.NoError(t, err)
293+
assert.Empty(t, changed)
294+
295+
removed, err := sources.SourcesForChangedPath(
296+
context.Background(),
297+
ChangedPathRequest{
298+
Path: filepath.Join(root, "skip", "removed.jsonl"),
299+
EventKind: "remove",
300+
WatchRoot: root,
301+
},
302+
)
303+
require.NoError(t, err)
304+
assert.Empty(t, removed)
305+
}
306+
269307
func TestJSONLSourceSetDuplicateKeysKeepFirstConfiguredRoot(t *testing.T) {
270308
firstRoot := t.TempDir()
271309
secondRoot := t.TempDir()

internal/parser/qwenpaw_provider.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package parser
33
import (
44
"context"
55
"fmt"
6+
"os"
67
"path/filepath"
78
"strings"
89
)
@@ -140,6 +141,7 @@ func newQwenPawSourceSet(roots []string) JSONLSourceSet {
140141
Hash: true,
141142
FollowSymlinkDirs: true,
142143
FollowSymlinkFiles: true,
144+
DescendPath: qwenPawDescendPath,
143145
IncludePath: isQwenPawSourcePath,
144146
ProjectHint: qwenPawProjectHintFromPath,
145147
SessionIDFromPath: qwenPawSessionIDFromPath,
@@ -207,6 +209,34 @@ func qwenPawSourcePathParts(root, path string) ([]string, bool) {
207209
return parts, true
208210
}
209211

212+
func qwenPawDescendPath(root, path string) bool {
213+
parts, ok := qwenPawSourcePathParts(root, path)
214+
if !ok {
215+
return false
216+
}
217+
switch len(parts) {
218+
case 1:
219+
return IsValidQwenPawIDPart(parts[0])
220+
case 2:
221+
return IsValidQwenPawIDPart(parts[0]) && parts[1] == "sessions"
222+
case 3:
223+
subdir := parts[2]
224+
if parts[1] != "sessions" ||
225+
!IsValidQwenPawIDPart(parts[0]) ||
226+
strings.HasPrefix(subdir, ".") ||
227+
!IsValidQwenPawIDPart(subdir) {
228+
return false
229+
}
230+
info, err := os.Lstat(path)
231+
if err != nil {
232+
return true
233+
}
234+
return info.Mode()&os.ModeSymlink == 0
235+
default:
236+
return false
237+
}
238+
}
239+
210240
func qwenPawProviderCapabilities() Capabilities {
211241
source := jsonlFileProviderSourceCapabilities()
212242
source.ForceReplaceOnParse = CapabilitySupported

internal/parser/qwenpaw_provider_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,47 @@ func TestQwenPawProviderDiscoversSymlinkedWorkspace(t *testing.T) {
189189
assert.Equal(t, "default", discovered[0].ProjectHint)
190190
}
191191

192+
func TestQwenPawProviderPrunesSymlinkedSessionNamespaces(t *testing.T) {
193+
root := t.TempDir()
194+
sourcePath := qwenPawProviderWriteSession(
195+
t, root, "default", "", "root_1", "root question",
196+
)
197+
targetDir := filepath.Join(t.TempDir(), "console-target")
198+
require.NoError(t, os.MkdirAll(targetDir, 0o755))
199+
require.NoError(t, os.WriteFile(
200+
filepath.Join(targetDir, "linked_1.json"),
201+
[]byte(qwenPawProviderFixture("linked question")),
202+
0o644,
203+
))
204+
linkedDir := filepath.Join(root, "default", "sessions", "linked")
205+
if err := os.Symlink(targetDir, linkedDir); err != nil {
206+
t.Skipf("symlink not supported: %v", err)
207+
}
208+
linkedPath := filepath.Join(linkedDir, "linked_1.json")
209+
210+
provider, ok := NewProvider(AgentQwenPaw, ProviderConfig{
211+
Roots: []string{root},
212+
Machine: "devbox",
213+
})
214+
require.True(t, ok)
215+
216+
discovered, err := provider.Discover(context.Background())
217+
require.NoError(t, err)
218+
require.Len(t, discovered, 1)
219+
assert.Equal(t, sourcePath, discovered[0].DisplayPath)
220+
221+
changed, err := provider.SourcesForChangedPath(
222+
context.Background(),
223+
ChangedPathRequest{
224+
Path: linkedPath,
225+
EventKind: "write",
226+
WatchRoot: root,
227+
},
228+
)
229+
require.NoError(t, err)
230+
assert.Empty(t, changed)
231+
}
232+
192233
func qwenPawProviderWriteSession(
193234
t *testing.T,
194235
root string,

0 commit comments

Comments
 (0)