Skip to content

Commit 0f7cc1b

Browse files
fix(parser): validate cowork deleted metadata candidates
Cowork metadata deletion recovery scans project directories after the metadata file is gone, so it cannot rely on the normal metadata-guided resolution path. It still needs the same transcript validity rules as normal discovery: regular files only, and symlink targets must stay inside the local session directory. Apply that validation before selecting or counting fallback candidates so symlink escapes are ignored and broken symlinks do not create false ambiguity. Validation: go test -tags "fts5" ./internal/parser -run 'TestCoworkProvider|TestResolveCoworkSessionRejectsSymlinkEscape|TestClassifyCoworkPath|TestParseCowork' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/...; git diff --check
1 parent ac244c0 commit 0f7cc1b

2 files changed

Lines changed: 101 additions & 5 deletions

File tree

internal/parser/cowork_provider.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,10 @@ func coworkTranscriptForMetadataPath(root, path string) (string, bool) {
353353
return "", false
354354
}
355355
sessionDir := strings.TrimSuffix(path, ".json")
356+
resolvedSessionDir, err := filepath.EvalSymlinks(sessionDir)
357+
if err != nil {
358+
return "", false
359+
}
356360
projectsDir := filepath.Join(sessionDir, ".claude", "projects")
357361
entries, err := os.ReadDir(projectsDir)
358362
if err != nil {
@@ -377,17 +381,33 @@ func coworkTranscriptForMetadataPath(root, path string) (string, bool) {
377381
continue
378382
}
379383
stem := strings.TrimSuffix(name, ".jsonl")
380-
if IsValidSessionID(stem) && !strings.HasPrefix(stem, "agent-") {
381-
if found != "" {
382-
return "", false
383-
}
384-
found = filepath.Join(projectDir, name)
384+
if !IsValidSessionID(stem) || strings.HasPrefix(stem, "agent-") {
385+
continue
386+
}
387+
candidate := filepath.Join(projectDir, name)
388+
if !validCoworkMainTranscriptCandidate(resolvedSessionDir, candidate) {
389+
continue
390+
}
391+
if found != "" {
392+
return "", false
385393
}
394+
found = candidate
386395
}
387396
}
388397
return found, found != ""
389398
}
390399

400+
func validCoworkMainTranscriptCandidate(resolvedSessionDir, candidate string) bool {
401+
if !IsRegularFile(candidate) {
402+
return false
403+
}
404+
resolved, err := filepath.EvalSymlinks(candidate)
405+
if err != nil {
406+
return false
407+
}
408+
return isContainedIn(resolved, resolvedSessionDir)
409+
}
410+
391411
func coworkProviderCapabilities() Capabilities {
392412
return Capabilities{
393413
Source: SourceCapabilities{

internal/parser/cowork_provider_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,82 @@ func TestCoworkProviderMetadataRemovalRejectsAmbiguousMainTranscripts(t *testing
273273
assert.Empty(t, changed)
274274
}
275275

276+
func TestCoworkProviderMetadataRemovalIgnoresSymlinkEscape(t *testing.T) {
277+
root := t.TempDir()
278+
cli := "c0000000-0000-4000-8000-000000000106"
279+
metaPath, _ := writeCoworkSession(t, root, coworkFixture{
280+
org: "org",
281+
workspace: "ws",
282+
sessionUUID: "50000000-0000-4000-8000-000000000106",
283+
cliSessionID: cli,
284+
encodedProject: "-sessions-demo",
285+
transcriptLines: coworkTranscriptLines(cli),
286+
})
287+
sessionDir := strings.TrimSuffix(metaPath, ".json")
288+
projectsDir := filepath.Join(sessionDir, ".claude", "projects")
289+
outside := filepath.Join(root, "outside")
290+
require.NoError(t, os.MkdirAll(outside, 0o755))
291+
writeSourceFile(
292+
t,
293+
filepath.Join(outside, "c0000000-0000-4000-8000-000000000107.jsonl"),
294+
strings.Join(coworkTranscriptLines("c0000000-0000-4000-8000-000000000107"), "\n")+"\n",
295+
)
296+
if err := os.Symlink(outside, filepath.Join(projectsDir, "-sessions-escape")); err != nil {
297+
t.Skipf("symlink not supported: %v", err)
298+
}
299+
300+
provider, ok := NewProvider(AgentCowork, ProviderConfig{
301+
Roots: []string{root},
302+
})
303+
require.True(t, ok)
304+
305+
require.NoError(t, os.Remove(metaPath))
306+
changed, err := provider.SourcesForChangedPath(
307+
context.Background(),
308+
ChangedPathRequest{Path: metaPath, EventKind: "remove", WatchRoot: root},
309+
)
310+
require.NoError(t, err)
311+
require.Len(t, changed, 1)
312+
assert.Equal(t, cli+".jsonl", filepath.Base(changed[0].DisplayPath))
313+
}
314+
315+
func TestCoworkProviderMetadataRemovalIgnoresBrokenSymlinkAmbiguity(t *testing.T) {
316+
root := t.TempDir()
317+
cli := "c0000000-0000-4000-8000-000000000108"
318+
metaPath, _ := writeCoworkSession(t, root, coworkFixture{
319+
org: "org",
320+
workspace: "ws",
321+
sessionUUID: "50000000-0000-4000-8000-000000000108",
322+
cliSessionID: cli,
323+
encodedProject: "-sessions-demo",
324+
transcriptLines: coworkTranscriptLines(cli),
325+
})
326+
sessionDir := strings.TrimSuffix(metaPath, ".json")
327+
projectsDir := filepath.Join(sessionDir, ".claude", "projects")
328+
brokenDir := filepath.Join(projectsDir, "-sessions-broken")
329+
require.NoError(t, os.MkdirAll(brokenDir, 0o755))
330+
if err := os.Symlink(
331+
filepath.Join(root, "missing.jsonl"),
332+
filepath.Join(brokenDir, "c0000000-0000-4000-8000-000000000109.jsonl"),
333+
); err != nil {
334+
t.Skipf("symlink not supported: %v", err)
335+
}
336+
337+
provider, ok := NewProvider(AgentCowork, ProviderConfig{
338+
Roots: []string{root},
339+
})
340+
require.True(t, ok)
341+
342+
require.NoError(t, os.Remove(metaPath))
343+
changed, err := provider.SourcesForChangedPath(
344+
context.Background(),
345+
ChangedPathRequest{Path: metaPath, EventKind: "remove", WatchRoot: root},
346+
)
347+
require.NoError(t, err)
348+
require.Len(t, changed, 1)
349+
assert.Equal(t, cli+".jsonl", filepath.Base(changed[0].DisplayPath))
350+
}
351+
276352
func TestCoworkProviderFullSessionIDPrefixLookup(t *testing.T) {
277353
root := t.TempDir()
278354
cli := "c0000000-0000-4000-8000-000000000103"

0 commit comments

Comments
 (0)