Skip to content

Commit 1faa4c0

Browse files
committed
fix(parser): propagate symlink resolution failures in remaining streaming discoverers
Claude, OpenClaw/QClaw, OpenCode storage, Grok, and Vibe streaming discovery silently treated a followed directory symlink as absent when statting its target failed (dangling link, unreadable parent). Reconciliation treats a clean DiscoverEach as authoritative, so a temporarily broken symlink could tombstone every session beneath it. Route all five discoverers through a shared streamingDirCandidateOrIncomplete helper that surfaces the failure as DiscoveryIncompleteError, and convert the Gemini site from cd41db0 to the same helper. Claude's project walk records the failure and continues with healthy siblings, matching its existing accumulator; the abort-style walkers (claw, opencode storage, grok, vibe) fail the scope like their other discovery errors. Name filters now run before the symlink stat so dangling links that cannot name a session are still skipped silently. This completes the isDirOrSymlink sweep for authoritative streaming discovery; the remaining call sites are legacy Discover enumeration, find-source lookups, changed-path mapping, and watch-root helpers, none of which gate reconciliation tombstoning.
1 parent f0cb59e commit 1faa4c0

12 files changed

Lines changed: 531 additions & 18 deletions

internal/parser/claude_provider.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,14 @@ func (s claudeSourceSet) streamLocalRoot(
276276
) error {
277277
var incomplete error
278278
err := streamDirectoryEntries(ctx, root, func(project os.DirEntry) error {
279-
if !isDirOrSymlink(project, root) {
279+
isProjectDir, dirErr := streamingDirCandidateOrIncomplete(
280+
AgentClaude, "Claude project directory", project, root,
281+
)
282+
if dirErr != nil {
283+
incomplete = errors.Join(incomplete, dirErr)
284+
return nil
285+
}
286+
if !isProjectDir {
280287
return nil
281288
}
282289
projectRoot := filepath.Join(root, project.Name())

internal/parser/claude_provider_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"os"
77
"path/filepath"
8+
"runtime"
89
"testing"
910

1011
"github.com/stretchr/testify/assert"
@@ -175,6 +176,89 @@ func TestClaudeProviderDiscoversSymlinkedProjectDirectory(t *testing.T) {
175176
assert.Equal(t, subagentPath, found.DisplayPath)
176177
}
177178

179+
// A followed project-directory symlink whose target cannot be resolved must
180+
// surface incomplete streaming discovery rather than reading as absent:
181+
// reconciliation treats a clean DiscoverEach as authoritative and would
182+
// tombstone every session beneath the symlink.
183+
func TestClaudeProviderStreamingDiscoveryPropagatesProjectSymlinkErrors(t *testing.T) {
184+
discoverEach := func(t *testing.T, root string) ([]string, error) {
185+
t.Helper()
186+
provider, ok := NewProvider(AgentClaude, ProviderConfig{
187+
Roots: []string{root},
188+
})
189+
require.True(t, ok)
190+
discoverer, ok := provider.(StreamingDiscoverer)
191+
require.True(t, ok)
192+
var yielded []string
193+
err := discoverer.DiscoverEach(t.Context(), func(source SourceRef) error {
194+
yielded = append(yielded, source.DisplayPath)
195+
return nil
196+
})
197+
return yielded, err
198+
}
199+
healthyPath := func(root string) string {
200+
return filepath.Join(root, "-Users-dev-code-demo", "session-main.jsonl")
201+
}
202+
203+
t.Run("dangling project symlink", func(t *testing.T) {
204+
root := t.TempDir()
205+
writeSourceFile(t, healthyPath(root), claudeProviderFixture("hello claude"))
206+
target := filepath.Join(t.TempDir(), "linked-project")
207+
require.NoError(t, os.MkdirAll(target, 0o755))
208+
link := filepath.Join(root, "linked")
209+
if err := os.Symlink(target, link); err != nil {
210+
t.Skipf("symlink not supported: %v", err)
211+
}
212+
require.NoError(t, os.RemoveAll(target))
213+
214+
yielded, err := discoverEach(t, root)
215+
216+
require.Error(t, err)
217+
assert.ErrorIs(t, err, os.ErrNotExist)
218+
var incomplete DiscoveryIncompleteError
219+
assert.ErrorAs(t, err, &incomplete)
220+
// The walker records the failure and continues with healthy siblings.
221+
assert.Equal(t, []string{healthyPath(root)}, yielded)
222+
223+
require.NoError(t, os.Remove(link))
224+
yielded, err = discoverEach(t, root)
225+
require.NoError(t, err)
226+
assert.Equal(t, []string{healthyPath(root)}, yielded)
227+
})
228+
229+
t.Run("unstatable project symlink target", func(t *testing.T) {
230+
if runtime.GOOS == "windows" {
231+
t.Skip("directory read permissions are not enforced on Windows")
232+
}
233+
if os.Geteuid() == 0 {
234+
t.Skip("root bypasses directory permissions")
235+
}
236+
root := t.TempDir()
237+
writeSourceFile(t, healthyPath(root), claudeProviderFixture("hello claude"))
238+
targetParent := t.TempDir()
239+
target := filepath.Join(targetParent, "linked-project")
240+
require.NoError(t, os.MkdirAll(target, 0o755))
241+
if err := os.Symlink(target, filepath.Join(root, "linked")); err != nil {
242+
t.Skipf("symlink not supported: %v", err)
243+
}
244+
require.NoError(t, os.Chmod(targetParent, 0o000))
245+
t.Cleanup(func() { _ = os.Chmod(targetParent, 0o755) })
246+
247+
yielded, err := discoverEach(t, root)
248+
249+
require.Error(t, err)
250+
assert.ErrorIs(t, err, os.ErrPermission)
251+
var incomplete DiscoveryIncompleteError
252+
assert.ErrorAs(t, err, &incomplete)
253+
assert.Equal(t, []string{healthyPath(root)}, yielded)
254+
255+
require.NoError(t, os.Chmod(targetParent, 0o755))
256+
yielded, err = discoverEach(t, root)
257+
require.NoError(t, err)
258+
assert.Equal(t, []string{healthyPath(root)}, yielded)
259+
})
260+
}
261+
178262
func TestClaudeProviderStreamingDiscoveryStopsAfterYieldError(t *testing.T) {
179263
root := t.TempDir()
180264
for _, project := range []string{"-Users-dev-code-one", "-Users-dev-code-two"} {

internal/parser/claw_provider.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,16 @@ func (s clawSourceSet) DiscoverEach(ctx context.Context, yield func(SourceRef) e
247247
return err
248248
}
249249
err := streamDirectoryEntries(ctx, root, func(agent os.DirEntry) error {
250-
if !isDirOrSymlink(agent, root) || !IsValidSessionID(agent.Name()) {
250+
if !IsValidSessionID(agent.Name()) {
251+
return nil
252+
}
253+
isAgentDir, dirErr := streamingDirCandidateOrIncomplete(
254+
s.spec.agent, "agent directory", agent, root,
255+
)
256+
if dirErr != nil {
257+
return dirErr
258+
}
259+
if !isAgentDir {
251260
return nil
252261
}
253262
dir := filepath.Join(root, agent.Name(), "sessions")

internal/parser/claw_provider_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"os"
66
"path/filepath"
7+
"runtime"
78
"testing"
89

910
"github.com/stretchr/testify/assert"
@@ -30,6 +31,102 @@ func TestQClawProviderDiscoversSymlinkedAgentDirectory(t *testing.T) {
3031
assertClawProviderDiscoversSymlinkedAgentDirectory(t, spec)
3132
}
3233

34+
func TestOpenClawProviderStreamingDiscoveryPropagatesAgentSymlinkErrors(t *testing.T) {
35+
spec := openClawProviderTestSpec()
36+
assertClawProviderStreamingDiscoveryPropagatesAgentSymlinkErrors(t, spec)
37+
}
38+
39+
func TestQClawProviderStreamingDiscoveryPropagatesAgentSymlinkErrors(t *testing.T) {
40+
spec := qClawProviderTestSpec()
41+
assertClawProviderStreamingDiscoveryPropagatesAgentSymlinkErrors(t, spec)
42+
}
43+
44+
// A followed agent-directory symlink whose target cannot be resolved must
45+
// surface incomplete streaming discovery rather than reading as absent:
46+
// reconciliation treats a clean DiscoverEach as authoritative and would
47+
// tombstone every session beneath the symlink.
48+
func assertClawProviderStreamingDiscoveryPropagatesAgentSymlinkErrors(
49+
t *testing.T, spec clawProviderTestSpec,
50+
) {
51+
t.Helper()
52+
discoverEach := func(t *testing.T, root string) ([]string, error) {
53+
t.Helper()
54+
provider, ok := NewProvider(spec.agent, ProviderConfig{
55+
Roots: []string{root},
56+
})
57+
require.True(t, ok)
58+
discoverer, ok := provider.(StreamingDiscoverer)
59+
require.True(t, ok)
60+
var yielded []string
61+
err := discoverer.DiscoverEach(t.Context(), func(source SourceRef) error {
62+
yielded = append(yielded, source.DisplayPath)
63+
return nil
64+
})
65+
return yielded, err
66+
}
67+
writeHealthySession := func(t *testing.T, root string) string {
68+
t.Helper()
69+
path := filepath.Join(root, "main", "sessions", "abc-123.jsonl")
70+
writeSourceFile(t, path, spec.fixture("abc-123", "healthy question"))
71+
return path
72+
}
73+
74+
t.Run("dangling agent symlink", func(t *testing.T) {
75+
root := t.TempDir()
76+
healthy := writeHealthySession(t, root)
77+
target := filepath.Join(t.TempDir(), "linked-agent")
78+
require.NoError(t, os.MkdirAll(target, 0o755))
79+
link := filepath.Join(root, "linked")
80+
if err := os.Symlink(target, link); err != nil {
81+
t.Skipf("symlink not supported: %v", err)
82+
}
83+
require.NoError(t, os.RemoveAll(target))
84+
85+
_, err := discoverEach(t, root)
86+
87+
require.Error(t, err)
88+
assert.ErrorIs(t, err, os.ErrNotExist)
89+
var incomplete DiscoveryIncompleteError
90+
assert.ErrorAs(t, err, &incomplete)
91+
92+
require.NoError(t, os.Remove(link))
93+
yielded, err := discoverEach(t, root)
94+
require.NoError(t, err)
95+
assert.Equal(t, []string{healthy}, yielded)
96+
})
97+
98+
t.Run("unstatable agent symlink target", func(t *testing.T) {
99+
if runtime.GOOS == "windows" {
100+
t.Skip("directory read permissions are not enforced on Windows")
101+
}
102+
if os.Geteuid() == 0 {
103+
t.Skip("root bypasses directory permissions")
104+
}
105+
root := t.TempDir()
106+
healthy := writeHealthySession(t, root)
107+
targetParent := t.TempDir()
108+
target := filepath.Join(targetParent, "linked-agent")
109+
require.NoError(t, os.MkdirAll(target, 0o755))
110+
if err := os.Symlink(target, filepath.Join(root, "linked")); err != nil {
111+
t.Skipf("symlink not supported: %v", err)
112+
}
113+
require.NoError(t, os.Chmod(targetParent, 0o000))
114+
t.Cleanup(func() { _ = os.Chmod(targetParent, 0o755) })
115+
116+
_, err := discoverEach(t, root)
117+
118+
require.Error(t, err)
119+
assert.ErrorIs(t, err, os.ErrPermission)
120+
var incomplete DiscoveryIncompleteError
121+
assert.ErrorAs(t, err, &incomplete)
122+
123+
require.NoError(t, os.Chmod(targetParent, 0o755))
124+
yielded, err := discoverEach(t, root)
125+
require.NoError(t, err)
126+
assert.Equal(t, []string{healthy}, yielded)
127+
})
128+
}
129+
33130
func TestOpenClawProviderParse(t *testing.T) {
34131
spec := openClawProviderTestSpec()
35132
assertClawProviderParse(t, spec)

internal/parser/gemini_provider.go

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,19 +161,11 @@ func (s geminiSourceSet) DiscoverEach(ctx context.Context, yield func(SourceRef)
161161
}
162162
tmpDir := filepath.Join(root, "tmp")
163163
err = streamDirectoryEntries(ctx, tmpDir, func(projectDir os.DirEntry) error {
164-
isProjectDir, dirErr := streamingDirOrSymlinkCandidate(projectDir, tmpDir)
164+
isProjectDir, dirErr := streamingDirCandidateOrIncomplete(
165+
AgentGemini, "Gemini project directory", projectDir, tmpDir,
166+
)
165167
if dirErr != nil {
166-
// A followed symlink whose target cannot be resolved
167-
// (dangling link, unreadable parent) must not read as an
168-
// absent project: reconciliation treats a clean streaming
169-
// discovery as authoritative and would tombstone every
170-
// session beneath the symlink.
171-
return incompleteDiscoveryError(
172-
AgentGemini,
173-
"resolve Gemini project directory "+
174-
filepath.Join(tmpDir, projectDir.Name()),
175-
dirErr,
176-
)
168+
return dirErr
177169
}
178170
if !isProjectDir {
179171
return nil

internal/parser/grok_provider.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,27 @@ func grokDiscoverEach(
3333
ctx context.Context, root string, yield func(singleFileMatch) error,
3434
) error {
3535
return streamDirectoryEntries(ctx, root, func(cwd os.DirEntry) error {
36-
if !isDirOrSymlink(cwd, root) {
36+
isCwdDir, dirErr := streamingDirCandidateOrIncomplete(
37+
AgentGrok, "Grok cwd directory", cwd, root,
38+
)
39+
if dirErr != nil {
40+
return dirErr
41+
}
42+
if !isCwdDir {
3743
return nil
3844
}
3945
cwdRoot := filepath.Join(root, cwd.Name())
4046
return streamDirectoryEntries(ctx, cwdRoot, func(session os.DirEntry) error {
41-
if !isDirOrSymlink(session, cwdRoot) || !IsValidSessionID(session.Name()) {
47+
if !IsValidSessionID(session.Name()) {
48+
return nil
49+
}
50+
isSessionDir, sessionErr := streamingDirCandidateOrIncomplete(
51+
AgentGrok, "Grok session directory", session, cwdRoot,
52+
)
53+
if sessionErr != nil {
54+
return sessionErr
55+
}
56+
if !isSessionDir {
4257
return nil
4358
}
4459
if match, ok := grokStrictMatch(

0 commit comments

Comments
 (0)