diff --git a/cmd/agentsview/session_export.go b/cmd/agentsview/session_export.go index 18b42bb1a..e03c43402 100644 --- a/cmd/agentsview/session_export.go +++ b/cmd/agentsview/session_export.go @@ -125,6 +125,17 @@ func newSessionExportCommand() *cobra.Command { } return err } + if dbPath, sessionID, ok := parser.SplitWindsurfVirtualPath(storedPath); ok { + err := parser.WriteWindsurfSessionJSON( + cmd.OutOrStdout(), dbPath, sessionID, + ) + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf( + "source file not found: %s", dbPath, + ) + } + return err + } path := parser.ResolveSourceFilePath(storedPath) f, err := os.Open(path) if err != nil { diff --git a/cmd/benchgate/main.go b/cmd/benchgate/main.go index 6b3003ff0..fa7a94b27 100644 --- a/cmd/benchgate/main.go +++ b/cmd/benchgate/main.go @@ -35,9 +35,11 @@ // // Lines that look like benchmark results but fail to parse (for // example test log output interleaved into a result line) are a -// corrupted capture: they are reported and the gate exits 2, because -// the corrupted benchmark would otherwise silently vanish from both -// sides and never gate again. +// corrupted capture. Candidate corruption exits 2, because it is +// under this workflow's control and would otherwise silently disable +// a gate. Baseline corruption is reported but treated as a partial +// baseline, because the merge base may legitimately predate fixes to +// the benchmark capture itself. package main import ( @@ -369,10 +371,11 @@ type results struct { oldSyntax, newSyntax []string } -// render formats the human-readable outcome and picks the exit -// code: 2 for unusable input or configuration errors, 1 for -// regressions, 0 otherwise. Violations always print, even when a -// config issue or corrupted capture also occurred, so a detected +// render formats the human-readable outcome and picks the exit code: +// 2 for unusable candidate input or configuration errors, 1 for +// regressions, 0 otherwise. Baseline syntax errors are reported as a +// partial baseline. Violations always print, even when a config issue +// or corrupted candidate capture also occurred, so a detected // regression is never hidden behind an exit-2. func render(r results) (string, int) { var b strings.Builder @@ -395,7 +398,7 @@ func render(r results) (string, int) { } } switch { - case len(r.oldSyntax)+len(r.newSyntax) > 0 || len(r.issues) > 0: + case len(r.newSyntax) > 0 || len(r.issues) > 0: return b.String(), 2 case r.newCount == 0: fmt.Fprintln(&b, "benchgate: candidate output contains no benchmarks") diff --git a/cmd/benchgate/main_test.go b/cmd/benchgate/main_test.go index b56043ddd..00102fb33 100644 --- a/cmd/benchgate/main_test.go +++ b/cmd/benchgate/main_test.go @@ -418,7 +418,7 @@ func TestRender(t *testing.T) { }, }, { - name: "corrupted capture exits 2 and is described", + name: "candidate corrupted capture exits 2 and is described", r: results{ newCount: 1, newSyntax: []string{"test:3: no iteration count"}, @@ -429,6 +429,32 @@ func TestRender(t *testing.T) { "no iteration count", }, }, + { + name: "baseline corrupted capture is reported but does not fail", + r: results{ + newCount: 1, + oldSyntax: []string{"test:3: no iteration count"}, + }, + wantCode: 0, + wantOut: []string{ + "baseline capture is corrupted", + "no iteration count", + "no regressions beyond thresholds", + }, + }, + { + name: "baseline corruption still allows regressions to fail", + r: results{ + violations: []violation{sampleViolation}, + newCount: 1, + oldSyntax: []string{"test:3: no iteration count"}, + }, + wantCode: 1, + wantOut: []string{ + "baseline capture is corrupted", + "1 regression(s)", + }, + }, { name: "empty candidate exits 2", r: results{newCount: 0}, diff --git a/internal/db/messages_bench_test.go b/internal/db/messages_bench_test.go index 73a9eebfb..893fd413d 100644 --- a/internal/db/messages_bench_test.go +++ b/internal/db/messages_bench_test.go @@ -3,6 +3,8 @@ package db import ( "encoding/json" "fmt" + "io" + "log" "testing" ) @@ -85,6 +87,7 @@ func seedBenchSession( // single in-place UPDATE; cost must not scale with the number of // unchanged stored rows being rewritten. func BenchmarkReplaceSessionMessagesStreamingMerge(b *testing.B) { + silenceBenchmarkLogs(b) const stored = 1000 d := testDB(b) msgs := seedBenchSession(b, d, "bench-replace", stored) @@ -115,6 +118,7 @@ func BenchmarkReplaceSessionMessagesStreamingMerge(b *testing.B) { // -benchtime=Nx (see bench.yml and the Makefile) so baseline and // candidate insert into identically sized databases. func BenchmarkInsertMessagesBatch(b *testing.B) { + silenceBenchmarkLogs(b) const batch = 200 d := testDB(b) @@ -144,3 +148,12 @@ func BenchmarkInsertMessagesBatch(b *testing.B) { } } } + +func silenceBenchmarkLogs(b *testing.B) { + b.Helper() + origLog := log.Writer() + log.SetOutput(io.Discard) + b.Cleanup(func() { + log.SetOutput(origLog) + }) +} diff --git a/internal/db/usage_test.go b/internal/db/usage_test.go index c82bf4035..a6e962233 100644 --- a/internal/db/usage_test.go +++ b/internal/db/usage_test.go @@ -3,6 +3,8 @@ package db import ( "context" "encoding/json" + "io" + "log" "math" "os" "path/filepath" @@ -3005,6 +3007,9 @@ func TestExcludeModelFilter(t *testing.T) { func BenchmarkGetDailyUsage(b *testing.B) { d := testDB(b) ctx := context.Background() + origLog := log.Writer() + log.SetOutput(io.Discard) + defer log.SetOutput(origLog) if err := d.UpsertModelPricing([]ModelPricing{ {ModelPattern: "claude-sonnet-4-20250514", @@ -3083,6 +3088,7 @@ func BenchmarkGetDailyUsage(b *testing.B) { } } + log.SetOutput(origLog) b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 14ff1d0d3..781b9e576 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -484,6 +484,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newVisualStudioCopilotProviderFactory(def) case AgentVSCodeCopilot: return newVSCodeCopilotProviderFactory(def) + case AgentWindsurf: + return newWindsurfProviderFactory(def) case AgentVibe: return newVibeProviderFactory(def) case AgentZCode: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index d3afd36b6..14ca67c75 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -31,6 +31,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentAmp: ProviderMigrationProviderAuthoritative, AgentZencoder: ProviderMigrationProviderAuthoritative, AgentVSCodeCopilot: ProviderMigrationProviderAuthoritative, + AgentWindsurf: ProviderMigrationProviderAuthoritative, AgentVSCopilot: ProviderMigrationProviderAuthoritative, AgentPi: ProviderMigrationProviderAuthoritative, AgentQwen: ProviderMigrationProviderAuthoritative, diff --git a/internal/parser/qoder.go b/internal/parser/qoder.go index 63047a68a..2dae78214 100644 --- a/internal/parser/qoder.go +++ b/internal/parser/qoder.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" ) @@ -165,9 +166,9 @@ func DecodeQoderProjectDir(encoded string) string { } } } - for i := len(parts) - 1; i >= 0; i-- { - if parts[i] != "" { - return NormalizeName(parts[i]) + for _, v := range slices.Backward(parts) { + if v != "" { + return NormalizeName(v) } } return NormalizeName(encoded) diff --git a/internal/parser/types.go b/internal/parser/types.go index ff08aa357..f1ab1baab 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -25,6 +25,7 @@ const ( AgentAmp AgentType = "amp" AgentZencoder AgentType = "zencoder" AgentVSCodeCopilot AgentType = "vscode-copilot" + AgentWindsurf AgentType = "windsurf" AgentVSCopilot AgentType = "visualstudio-copilot" AgentPi AgentType = "pi" AgentOMP AgentType = "omp" @@ -286,6 +287,32 @@ var Registry = []AgentDef{ AICreditsDenominated: true, }, }, + { + Type: AgentWindsurf, + DisplayName: "Windsurf", + EnvVar: "WINDSURF_DIR", + ConfigKey: "windsurf_dirs", + DefaultDirs: []string{ + // Windows + "AppData/Roaming/Windsurf/User", + "AppData/Roaming/Windsurf - Next/User", + // macOS + "Library/Application Support/Windsurf/User", + "Library/Application Support/Windsurf - Next/User", + // Linux + ".config/Windsurf/User", + ".config/Windsurf - Next/User", + }, + IDPrefix: "windsurf:", + WatchSubdirs: []string{ + "workspaceStorage", + }, + FileBased: true, + Usage: UsageCapabilities{ + NoPerMessageTokenData: true, + AICreditsDenominated: true, + }, + }, { Type: AgentVSCopilot, DisplayName: "Visual Studio Copilot", diff --git a/internal/parser/types_test.go b/internal/parser/types_test.go index a46752f90..0e05163ab 100644 --- a/internal/parser/types_test.go +++ b/internal/parser/types_test.go @@ -423,6 +423,7 @@ func TestRegistryCompleteness(t *testing.T) { AgentCursor, AgentAmp, AgentVSCodeCopilot, + AgentWindsurf, AgentVSCopilot, AgentPi, AgentOMP, @@ -1154,6 +1155,35 @@ func TestVSCodeCopilotDefaultDirs(t *testing.T) { } } +func TestWindsurfRegistryEntry(t *testing.T) { + def, ok := AgentByType(AgentWindsurf) + require.True(t, ok, "AgentWindsurf not in Registry") + + assert.Equal(t, "Windsurf", def.DisplayName) + assert.Equal(t, "WINDSURF_DIR", def.EnvVar) + assert.Equal(t, "windsurf_dirs", def.ConfigKey) + assert.Equal(t, "windsurf:", def.IDPrefix) + assert.True(t, def.FileBased) + assert.Contains(t, def.WatchSubdirs, "workspaceStorage") + + required := []string{ + "AppData/Roaming/Windsurf/User", + "AppData/Roaming/Windsurf - Next/User", + "Library/Application Support/Windsurf/User", + "Library/Application Support/Windsurf - Next/User", + ".config/Windsurf/User", + ".config/Windsurf - Next/User", + } + for _, path := range required { + assert.Truef(t, slices.Contains(def.DefaultDirs, path), + "missing default dir: %s", path) + } + + byPrefix, ok := AgentByPrefix("windsurf:session-a") + require.True(t, ok) + assert.Equal(t, AgentWindsurf, byPrefix.Type) +} + func TestApplyUsageEventTokenTotals(t *testing.T) { // Verify that applyUsageEventTokenTotals computes PeakContextTokens // correctly including cache-creation and cache-read tokens. diff --git a/internal/parser/visualstudio_copilot.go b/internal/parser/visualstudio_copilot.go index 1cdbf2dd5..47576ef68 100644 --- a/internal/parser/visualstudio_copilot.go +++ b/internal/parser/visualstudio_copilot.go @@ -116,8 +116,10 @@ func isVisualStudioCopilotVS2026Hex(c rune) bool { // be opened on disk. Visual Studio Copilot stores a // # virtual path whose conversations share one // physical trace file, and aider stores a # virtual -// path whose runs share one physical history file; both resolve to the -// physical file. Every other agent stores a real path, returned unchanged. +// path whose runs share one physical history file, and Windsurf stores a +// # virtual path whose chats share one SQLite DB. +// These resolve to the physical source file. Every other agent stores a real +// path, returned unchanged. func ResolveSourceFilePath(storedPath string) string { if tracePath, _, ok := splitVisualStudioCopilotVirtualPath(storedPath); ok { return tracePath @@ -125,6 +127,9 @@ func ResolveSourceFilePath(storedPath string) string { if historyPath, _, ok := ParseAiderVirtualPath(storedPath); ok { return historyPath } + if dbPath, _, ok := SplitWindsurfVirtualPath(storedPath); ok { + return dbPath + } return storedPath } diff --git a/internal/parser/visualstudio_copilot_test.go b/internal/parser/visualstudio_copilot_test.go index 40443f876..e0c363007 100644 --- a/internal/parser/visualstudio_copilot_test.go +++ b/internal/parser/visualstudio_copilot_test.go @@ -493,6 +493,14 @@ func TestResolveSourceFilePath(t *testing.T) { VisualStudioCopilotVirtualPath(sessionPath, conversationID), ), "VS 2026 session virtual path should resolve to its physical session file") + assert.Equal(t, "/profile/User/workspaceStorage/hash/state.vscdb", + ResolveSourceFilePath( + "/profile/User/workspaceStorage/hash/state.vscdb#windsurf-session", + ), + "Windsurf virtual path should resolve to its physical workspace DB") + assert.Equal(t, "/logs/session#draft.jsonl", + ResolveSourceFilePath("/logs/session#draft.jsonl"), + "non-Windsurf paths containing # should be returned unchanged") assert.Equal(t, "/logs/session.jsonl", ResolveSourceFilePath("/logs/session.jsonl"), "a plain source path should be returned unchanged") diff --git a/internal/parser/windsurf_provider.go b/internal/parser/windsurf_provider.go new file mode 100644 index 000000000..9e5f531e8 --- /dev/null +++ b/internal/parser/windsurf_provider.go @@ -0,0 +1,948 @@ +package parser + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + _ "github.com/mattn/go-sqlite3" +) + +const ( + windsurfStateDBName = "state.vscdb" + + // WindsurfStateDBName is the shared SQLite store used by Windsurf workspace chat. + WindsurfStateDBName = windsurfStateDBName +) + +var windsurfChatDataKeys = []string{ + "workbench.panel.aichat.view.aichat.chatdata", + "aiChat.chatdata", +} + +var _ Provider = (*windsurfProvider)(nil) + +type windsurfProviderFactory struct { + def AgentDef +} + +func newWindsurfProviderFactory(def AgentDef) ProviderFactory { + return windsurfProviderFactory{def: cloneAgentDef(def)} +} + +func (f windsurfProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f windsurfProviderFactory) Capabilities() Capabilities { + return windsurfProviderCapabilities() +} + +func (f windsurfProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &windsurfProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: windsurfProviderCapabilities(), + Config: cfg, + }, + sources: newWindsurfSourceSet(cfg.Roots), + } +} + +type windsurfProvider struct { + ProviderBase + sources windsurfSourceSet +} + +func (p *windsurfProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *windsurfProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *windsurfProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *windsurfProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = ProviderFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *windsurfProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *windsurfProvider) Parse( + ctx context.Context, + req ParseRequest, +) (ParseOutcome, error) { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + src, ok := p.sources.sourceFromRef(req.Source) + if !ok { + return ParseOutcome{}, fmt.Errorf("windsurf source path unavailable") + } + if _, err := os.Stat(src.DBPath); err != nil { + if os.IsNotExist(err) { + return ParseOutcome{ + ResultSetComplete: true, + SkipReason: SkipNoSession, + }, nil + } + return ParseOutcome{}, fmt.Errorf("stat %s: %w", src.DBPath, err) + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + sess, msgs, err := parseWindsurfSession( + src.DBPath, src.SessionID, src.Project, machine, src.VirtualPath, + ) + if err == sql.ErrNoRows { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + if err != nil { + return ParseOutcome{}, err + } + if sess == nil { + return ParseOutcome{ + ResultSetComplete: true, + ForceReplace: true, + SkipReason: SkipNoSession, + }, nil + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + sess.File.Size = req.Fingerprint.Size + sess.File.Mtime = req.Fingerprint.MTimeNS + } + return ParseOutcome{ + Results: []ParseResultOutcome{{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + UsageEvents: sess.UsageEvents, + }, + DataVersion: DataVersionCurrent, + }}, + ResultSetComplete: true, + ForceReplace: true, + }, nil +} + +type windsurfSource struct { + Root string + DBPath string + SessionID string + Project string + VirtualPath string +} + +type windsurfSourceSet struct { + roots []string +} + +func newWindsurfSourceSet(roots []string) windsurfSourceSet { + return windsurfSourceSet{roots: cleanJSONLRoots(roots)} +} + +func (s windsurfSourceSet) Discover(ctx context.Context) ([]SourceRef, error) { + var sources []SourceRef + seen := make(map[string]struct{}) + for _, root := range s.roots { + if err := ctx.Err(); err != nil { + return nil, err + } + dbs := s.workspaceDBs(root) + for _, db := range dbs { + records, err := listWindsurfSessionRecords(db.DBPath) + if err != nil { + return nil, err + } + for _, record := range records { + ref := s.newSourceRef(root, db.DBPath, record.SessionID, db.Project) + ref.DiscoveryMTimeNS = db.MTimeNS + addJSONLSource(ref, &sources, seen) + } + } + } + sortJSONLSources(sources) + return sources, nil +} + +func (s windsurfSourceSet) WatchPlan(context.Context) (WatchPlan, error) { + roots := make([]WatchRoot, 0, len(s.roots)) + for _, root := range s.roots { + workspace := windsurfWorkspaceRoot(root) + roots = append(roots, WatchRoot{ + Path: workspace, + Recursive: true, + IncludeGlobs: []string{windsurfStateDBName, windsurfStateDBName + "-wal", "workspace.json"}, + DebounceKey: string(AgentWindsurf) + ":workspace:" + workspace, + }) + } + return WatchPlan{Roots: roots}, nil +} + +func (s windsurfSourceSet) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, root := range s.roots { + dbPath, ok := s.dbPathForEvent(root, req) + if !ok { + continue + } + if _, err := os.Stat(dbPath); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("stat %s: %w", dbPath, err) + } + sources, err := s.sourcesForDB(root, dbPath) + if err != nil { + return nil, err + } + for _, path := range req.StoredSourcePaths { + ref, ok := s.sourceRef(root, path) + if !ok { + continue + } + src := ref.Opaque.(windsurfSource) + if samePath(src.DBPath, dbPath) { + sources = append(sources, ref) + } + } + sortJSONLSources(sources) + return sources, nil + } + return nil, nil +} + +func (s windsurfSourceSet) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + if err := ctx.Err(); err != nil { + return SourceRef{}, false, err + } + for _, path := range []string{req.StoredFilePath, req.FingerprintKey} { + if path == "" { + continue + } + for _, root := range s.roots { + ref, ok := s.sourceRef(root, path) + if !ok { + continue + } + if !req.RequireFreshSource { + return ref, true, nil + } + src := ref.Opaque.(windsurfSource) + exists, err := windsurfDBHasSession(src.DBPath, src.SessionID) + if err != nil { + return SourceRef{}, false, err + } + if exists { + return ref, true, nil + } + } + } + if req.RawSessionID == "" { + return SourceRef{}, false, nil + } + for _, root := range s.roots { + for _, db := range s.workspaceDBs(root) { + records, err := listWindsurfSessionRecords(db.DBPath) + if err != nil { + return SourceRef{}, false, err + } + for _, record := range records { + if record.SessionID == req.RawSessionID { + return s.newSourceRef(root, db.DBPath, record.SessionID, db.Project), true, nil + } + } + } + } + return SourceRef{}, false, nil +} + +func (s windsurfSourceSet) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + if err := ctx.Err(); err != nil { + return SourceFingerprint{}, err + } + src, ok := s.sourceFromRef(source) + if !ok { + return SourceFingerprint{}, fmt.Errorf("windsurf source path unavailable") + } + info, err := os.Stat(src.DBPath) + if err != nil { + if os.IsNotExist(err) { + return SourceFingerprint{ + Key: firstNonEmptyJSONLString( + source.FingerprintKey, + source.Key, + src.VirtualPath, + ), + }, nil + } + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", src.DBPath, err) + } + workspacePath := windsurfWorkspaceManifestPath(src.DBPath) + combined := antigravityCLICombinedFileInfo( + info, + src.DBPath+"-wal", + workspacePath, + ) + hash, err := windsurfSourceHash(src.DBPath, workspacePath) + if err != nil { + return SourceFingerprint{}, err + } + return SourceFingerprint{ + Key: firstNonEmptyJSONLString(source.FingerprintKey, source.Key, src.VirtualPath), + Size: combined.Size(), + MTimeNS: combined.ModTime().UnixNano(), + Hash: hash, + }, nil +} + +func (s windsurfSourceSet) sourceFromRef(source SourceRef) (windsurfSource, bool) { + switch src := source.Opaque.(type) { + case windsurfSource: + return src, src.DBPath != "" && src.SessionID != "" + case *windsurfSource: + if src != nil && src.DBPath != "" && src.SessionID != "" { + return *src, true + } + } + for _, candidate := range []string{source.DisplayPath, source.FingerprintKey, source.Key} { + for _, root := range s.roots { + if ref, ok := s.sourceRef(root, candidate); ok { + return ref.Opaque.(windsurfSource), true + } + } + } + return windsurfSource{}, false +} + +func (s windsurfSourceSet) sourceRef(root, virtualPath string) (SourceRef, bool) { + dbPath, sessionID, ok := splitWindsurfVirtualPath(virtualPath) + if !ok || sessionID == "" { + return SourceRef{}, false + } + if !s.dbBelongsToRoot(root, dbPath) { + return SourceRef{}, false + } + return s.newSourceRef( + root, + dbPath, + sessionID, + windsurfWorkspaceProject(dbPath), + ), true +} + +func (s windsurfSourceSet) newSourceRef( + root, dbPath, sessionID, project string, +) SourceRef { + virtualPath := windsurfVirtualPath(dbPath, sessionID) + return SourceRef{ + Provider: AgentWindsurf, + Key: virtualPath, + DisplayPath: virtualPath, + FingerprintKey: virtualPath, + ProjectHint: project, + Opaque: windsurfSource{ + Root: root, + DBPath: dbPath, + SessionID: sessionID, + Project: project, + VirtualPath: virtualPath, + }, + } +} + +func (s windsurfSourceSet) sourcesForDB(root, dbPath string) ([]SourceRef, error) { + records, err := listWindsurfSessionRecords(dbPath) + if err != nil { + return nil, err + } + project := windsurfWorkspaceProject(dbPath) + sources := make([]SourceRef, 0, len(records)) + seen := make(map[string]struct{}, len(records)) + for _, record := range records { + addJSONLSource( + s.newSourceRef(root, dbPath, record.SessionID, project), + &sources, + seen, + ) + } + return sources, nil +} + +func (s windsurfSourceSet) dbPathForEvent( + root string, + req ChangedPathRequest, +) (string, bool) { + if req.WatchRoot != "" { + want := windsurfWorkspaceRoot(root) + if !samePath(req.WatchRoot, want) { + return "", false + } + } + workspaceRoot := windsurfWorkspaceRoot(root) + path := filepath.Clean(req.Path) + rel, ok := relUnder(workspaceRoot, path) + if !ok { + return "", false + } + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) != 2 { + return "", false + } + switch parts[1] { + case windsurfStateDBName, windsurfStateDBName + "-wal", "workspace.json": + return filepath.Join(workspaceRoot, parts[0], windsurfStateDBName), true + default: + return "", false + } +} + +func (s windsurfSourceSet) dbBelongsToRoot(root, dbPath string) bool { + workspaceRoot := windsurfWorkspaceRoot(root) + rel, ok := relUnder(workspaceRoot, filepath.Clean(dbPath)) + if !ok { + return false + } + parts := strings.Split(filepath.ToSlash(rel), "/") + return len(parts) == 2 && parts[1] == windsurfStateDBName +} + +type windsurfWorkspaceDB struct { + DBPath string + Project string + MTimeNS int64 +} + +func (s windsurfSourceSet) workspaceDBs(root string) []windsurfWorkspaceDB { + workspaceRoot := windsurfWorkspaceRoot(root) + entries, err := os.ReadDir(workspaceRoot) + if err != nil { + return nil + } + dbs := make([]windsurfWorkspaceDB, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dbPath := filepath.Join(workspaceRoot, entry.Name(), windsurfStateDBName) + info, err := os.Stat(dbPath) + if err != nil || info.IsDir() { + continue + } + dbs = append(dbs, windsurfWorkspaceDB{ + DBPath: dbPath, + Project: windsurfWorkspaceProject(dbPath), + MTimeNS: info.ModTime().UnixNano(), + }) + } + sort.Slice(dbs, func(i, j int) bool { + return dbs[i].DBPath < dbs[j].DBPath + }) + return dbs +} + +func windsurfWorkspaceRoot(root string) string { + clean := filepath.Clean(root) + if filepath.Base(clean) == "workspaceStorage" { + return clean + } + return filepath.Join(clean, "workspaceStorage") +} + +type windsurfSessionRecord struct { + SessionID string + Data []byte +} + +type windsurfChatValue struct { + Key string + Value string +} + +func listWindsurfSessionRecords(dbPath string) ([]windsurfSessionRecord, error) { + values, err := readWindsurfChatValues(dbPath) + if err != nil { + return nil, err + } + seen := make(map[string]struct{}) + var records []windsurfSessionRecord + for _, value := range values { + next, err := windsurfRecordsFromValue( + []byte(value.Value), + windsurfFallbackSessionID(dbPath), + ) + if err != nil { + return nil, err + } + for _, record := range next { + if _, ok := seen[record.SessionID]; ok { + continue + } + seen[record.SessionID] = struct{}{} + records = append(records, record) + } + } + sort.Slice(records, func(i, j int) bool { + return records[i].SessionID < records[j].SessionID + }) + return records, nil +} + +func readWindsurfChatValues(dbPath string) ([]windsurfChatValue, error) { + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return nil, nil + } + db, err := openWindsurfDB(dbPath) + if err != nil { + return nil, err + } + defer db.Close() + + values := make([]windsurfChatValue, 0, len(windsurfChatDataKeys)) + for _, key := range windsurfChatDataKeys { + var value string + err := db.QueryRow( + `SELECT value FROM ItemTable WHERE key = ?`, + key, + ).Scan(&value) + if err == sql.ErrNoRows { + continue + } + if err != nil { + return nil, fmt.Errorf("read windsurf chat data: %w", err) + } + values = append(values, windsurfChatValue{ + Key: key, + Value: value, + }) + } + return values, nil +} + +func windsurfDBHasSession(dbPath, sessionID string) (bool, error) { + records, err := listWindsurfSessionRecords(dbPath) + if err != nil { + return false, err + } + for _, record := range records { + if record.SessionID == sessionID { + return true, nil + } + } + return false, nil +} + +func parseWindsurfSession( + dbPath, sessionID, project, machine, virtualPath string, +) (*ParsedSession, []ParsedMessage, error) { + record, err := loadWindsurfSessionRecord(dbPath, sessionID) + if err != nil { + return nil, nil, err + } + sess, msgs, err := parseVSCodeCopilotData( + record.Data, + virtualPath, + project, + machine, + ) + if err != nil { + return nil, nil, err + } + if sess == nil { + return nil, nil, nil + } + info, err := os.Stat(dbPath) + if err != nil { + return nil, nil, fmt.Errorf("stat %s: %w", dbPath, err) + } + combined := antigravityCLICombinedFileInfo( + info, + dbPath+"-wal", + windsurfWorkspaceManifestPath(dbPath), + ) + sess.Agent = AgentWindsurf + sess.ID = "windsurf:" + strings.TrimPrefix(sess.ID, "windsurf:") + sess.File = FileInfo{ + Path: virtualPath, + Size: combined.Size(), + Mtime: combined.ModTime().UnixNano(), + } + for i := range sess.UsageEvents { + sess.UsageEvents[i].SessionID = sess.ID + sess.UsageEvents[i].Source = string(AgentWindsurf) + } + return sess, msgs, nil +} + +func loadWindsurfSessionRecord( + dbPath, sessionID string, +) (windsurfSessionRecord, error) { + records, err := listWindsurfSessionRecords(dbPath) + if err != nil { + return windsurfSessionRecord{}, err + } + for _, record := range records { + if record.SessionID == sessionID { + return record, nil + } + } + return windsurfSessionRecord{}, sql.ErrNoRows +} + +func openWindsurfDB(dbPath string) (*sql.DB, error) { + dsn := "file:" + sqliteURIPath(dbPath) + "?mode=ro&immutable=0&_busy_timeout=3000" + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("open windsurf db %s: %w", dbPath, err) + } + return db, nil +} + +func windsurfRecordsFromValue( + data []byte, + fallbackSessionID string, +) ([]windsurfSessionRecord, error) { + var session vscodeCopilotSession + if err := json.Unmarshal(data, &session); err == nil && + len(session.Requests) > 0 { + id := session.SessionID + if id == "" { + id = fallbackSessionID + session.SessionID = id + payload, err := json.Marshal(session) + if err != nil { + return nil, err + } + data = payload + } + return []windsurfSessionRecord{{ + SessionID: id, + Data: append([]byte(nil), data...), + }}, nil + } + + var chatData windsurfChatData + if err := json.Unmarshal(data, &chatData); err != nil { + return nil, fmt.Errorf("parse windsurf chatdata: %w", err) + } + records := make([]windsurfSessionRecord, 0, len(chatData.Tabs)) + for _, tab := range chatData.Tabs { + session, ok := tab.toVSCodeSession() + if !ok { + continue + } + payload, err := json.Marshal(session) + if err != nil { + return nil, err + } + records = append(records, windsurfSessionRecord{ + SessionID: session.SessionID, + Data: payload, + }) + } + return records, nil +} + +func windsurfFallbackSessionID(dbPath string) string { + workspaceID := strings.TrimSpace(filepath.Base(filepath.Dir(dbPath))) + if workspaceID == "" || workspaceID == "." || workspaceID == string(filepath.Separator) { + hash := sha256.Sum256([]byte(filepath.ToSlash(dbPath))) + return fmt.Sprintf("workspace-%x", hash[:8]) + } + return "workspace-" + workspaceID +} + +type windsurfChatData struct { + Tabs []windsurfChatTab `json:"tabs"` +} + +type windsurfChatTab struct { + TabID string `json:"tabId"` + ChatTitle string `json:"chatTitle"` + Bubbles []windsurfChatBubble `json:"bubbles"` +} + +type windsurfChatBubble struct { + Type windsurfBubbleType `json:"type"` + Text string `json:"text"` + RawText string `json:"rawText"` + InitText string `json:"initText"` +} + +type windsurfBubbleType string + +func (t *windsurfBubbleType) UnmarshalJSON(data []byte) error { + var value string + if err := json.Unmarshal(data, &value); err == nil { + *t = windsurfBubbleType(value) + return nil + } + var number json.Number + if err := json.Unmarshal(data, &number); err == nil { + *t = windsurfBubbleType(number.String()) + return nil + } + *t = "" + return nil +} + +func (t windsurfChatTab) toVSCodeSession() (vscodeCopilotSession, bool) { + id := strings.TrimSpace(t.TabID) + if id == "" || len(t.Bubbles) == 0 { + return vscodeCopilotSession{}, false + } + session := vscodeCopilotSession{ + Version: 1, + SessionID: id, + CustomTitle: t.ChatTitle, + } + var current *vscodeCopilotRequest + flush := func() { + if current == nil { + return + } + session.Requests = append(session.Requests, *current) + current = nil + } + for i, bubble := range t.Bubbles { + content := strings.TrimSpace(bubble.content()) + if content == "" { + continue + } + if bubble.isAssistant() { + if current == nil { + current = &vscodeCopilotRequest{ + RequestID: fmt.Sprintf("%s-%d", id, i), + } + } + current.Response = append(current.Response, windsurfResponseItem(content)) + continue + } + flush() + current = &vscodeCopilotRequest{ + RequestID: fmt.Sprintf("%s-%d", id, i), + Message: vscodeCopilotMessage{ + Text: content, + }, + } + } + flush() + return session, len(session.Requests) > 0 +} + +func (b windsurfChatBubble) content() string { + for _, value := range []string{b.Text, b.RawText, b.InitText} { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func (b windsurfChatBubble) isAssistant() bool { + switch strings.ToLower(strings.TrimSpace(string(b.Type))) { + case "2", "ai", "assistant": + return true + default: + return false + } +} + +func windsurfResponseItem(content string) json.RawMessage { + data, _ := json.Marshal(map[string]string{"value": content}) + return data +} + +func windsurfVirtualPath(dbPath, sessionID string) string { + return dbPath + "#" + sessionID +} + +func SplitWindsurfVirtualPath(path string) (string, string, bool) { + return splitWindsurfVirtualPath(path) +} + +func splitWindsurfVirtualPath(path string) (string, string, bool) { + return ParseVirtualSourcePathForBase(path, windsurfStateDBName) +} + +func WriteWindsurfSessionJSON(w io.Writer, dbPath, sessionID string) error { + record, err := loadWindsurfSessionRecord(dbPath, sessionID) + if err == sql.ErrNoRows { + return fmt.Errorf( + "windsurf session %s not found in %s: %w", + sessionID, dbPath, os.ErrNotExist, + ) + } + if err != nil { + return err + } + _, err = w.Write(record.Data) + return err +} + +func WriteSanitizedWindsurfStateDB(dstPath, dbPath string) error { + values, err := readWindsurfChatValues(dbPath) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return fmt.Errorf("create windsurf export dir: %w", err) + } + if err := os.Remove(dstPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("replace windsurf export db %s: %w", dstPath, err) + } + + dst, err := sql.Open( + "sqlite3", + "file:"+sqliteURIPath(dstPath)+"?mode=rwc&_busy_timeout=3000", + ) + if err != nil { + return fmt.Errorf("open sanitized windsurf db %s: %w", dstPath, err) + } + complete := false + defer func() { + _ = dst.Close() + if !complete { + _ = os.Remove(dstPath) + } + }() + + if _, err := dst.Exec(`PRAGMA journal_mode=DELETE`); err != nil { + return fmt.Errorf("configure sanitized windsurf db: %w", err) + } + if _, err := dst.Exec(`CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)`); err != nil { + return fmt.Errorf("create sanitized windsurf ItemTable: %w", err) + } + tx, err := dst.Begin() + if err != nil { + return fmt.Errorf("begin sanitized windsurf export: %w", err) + } + stmt, err := tx.Prepare(`INSERT INTO ItemTable (key, value) VALUES (?, ?)`) + if err != nil { + _ = tx.Rollback() + return fmt.Errorf("prepare sanitized windsurf export: %w", err) + } + for _, value := range values { + if _, err := stmt.Exec(value.Key, value.Value); err != nil { + _ = stmt.Close() + _ = tx.Rollback() + return fmt.Errorf("write sanitized windsurf chat key: %w", err) + } + } + if err := stmt.Close(); err != nil { + _ = tx.Rollback() + return fmt.Errorf("close sanitized windsurf export: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit sanitized windsurf export: %w", err) + } + if err := dst.Close(); err != nil { + return fmt.Errorf("close sanitized windsurf db: %w", err) + } + complete = true + return nil +} + +func windsurfWorkspaceManifestPath(dbPath string) string { + return filepath.Join(filepath.Dir(dbPath), "workspace.json") +} + +func windsurfWorkspaceProject(dbPath string) string { + project := readVSCodeWorkspaceManifest(filepath.Dir(dbPath)) + if project == "" { + return "unknown" + } + return project +} + +func windsurfSourceHash(dbPath, workspacePath string) (string, error) { + h := sha256.New() + if IsRegularFile(dbPath) { + values, err := readWindsurfChatValues(dbPath) + if err != nil { + return "", err + } + for _, value := range values { + _, _ = h.Write([]byte("chat")) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(value.Key)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(value.Value)) + _, _ = h.Write([]byte{0}) + } + } + if workspacePath != "" && IsRegularFile(workspacePath) { + hash, err := hashJSONLSourceFile(workspacePath) + if err != nil { + return "", err + } + _, _ = h.Write([]byte("workspace")) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(hash)) + _, _ = h.Write([]byte{0}) + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +func windsurfProviderCapabilities() Capabilities { + return Capabilities{ + Source: SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + IncrementalAppend: CapabilityNotApplicable, + MultiSessionSource: CapabilityNotApplicable, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilitySupported, + }, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + Thinking: CapabilitySupported, + AggregateUsageEvents: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/windsurf_provider_test.go b/internal/parser/windsurf_provider_test.go new file mode 100644 index 000000000..91a69f120 --- /dev/null +++ b/internal/parser/windsurf_provider_test.go @@ -0,0 +1,485 @@ +package parser + +import ( + "bytes" + "context" + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWindsurfProviderDiscoversAndParsesWorkspaceSQLiteChat(t *testing.T) { + root, dbPath := windsurfProviderFixture(t, windsurfVSCodeSessionJSON( + "windsurf-session-1", + "How do I add support?", + "Use the existing parser.", + )) + provider := newTestWindsurfProvider(root) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + source := sources[0] + assert.Equal(t, AgentWindsurf, source.Provider) + assert.Equal(t, "demo-workspace", source.ProjectHint) + assert.Equal(t, dbPath+"#windsurf-session-1", source.DisplayPath) + + fp, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + require.NotZero(t, fp.Size) + require.NotZero(t, fp.MTimeNS) + require.NotEmpty(t, fp.Hash) + + out, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Fingerprint: fp, + Machine: "machine-a", + }) + require.NoError(t, err) + require.True(t, out.ResultSetComplete) + require.Len(t, out.Results, 1) + + result := out.Results[0].Result + assert.Equal(t, "windsurf:windsurf-session-1", result.Session.ID) + assert.Equal(t, AgentWindsurf, result.Session.Agent) + assert.Equal(t, "demo-workspace", result.Session.Project) + assert.Equal(t, "machine-a", result.Session.Machine) + assert.Equal(t, source.DisplayPath, result.Session.File.Path) + require.Len(t, result.Messages, 2) + assert.Equal(t, RoleUser, result.Messages[0].Role) + assert.Equal(t, "How do I add support?", result.Messages[0].Content) + assert.Equal(t, RoleAssistant, result.Messages[1].Role) + assert.Equal(t, "Use the existing parser.", result.Messages[1].Content) +} + +func TestWindsurfProviderFingerprintHashIsContentBased(t *testing.T) { + payload := windsurfVSCodeSessionJSON( + "windsurf-session-hash", + "Hash this", + "Same content.", + ) + rootA, _ := windsurfProviderFixture(t, payload) + rootB, _ := windsurfProviderFixture(t, payload) + providerA := newTestWindsurfProvider(rootA) + providerB := newTestWindsurfProvider(rootB) + + sourcesA, err := providerA.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sourcesA, 1) + sourcesB, err := providerB.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sourcesB, 1) + fpA, err := providerA.Fingerprint(context.Background(), sourcesA[0]) + require.NoError(t, err) + fpB, err := providerB.Fingerprint(context.Background(), sourcesB[0]) + require.NoError(t, err) + + require.NotEmpty(t, fpA.Hash) + assert.Equal(t, fpA.Hash, fpB.Hash) +} + +func TestWindsurfProviderFingerprintIgnoresSHM(t *testing.T) { + root, dbPath := windsurfProviderFixture(t, windsurfVSCodeSessionJSON( + "windsurf-session-shm", + "Ignore SHM", + "Use chat data only.", + )) + provider := newTestWindsurfProvider(root) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + require.NoError(t, os.WriteFile(dbPath+"-shm", []byte("first"), 0o644)) + firstTime := mustParseTestTime(t, "2026-06-28T12:00:00Z") + require.NoError(t, os.Chtimes(dbPath+"-shm", firstTime, firstTime)) + + before, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(dbPath+"-shm", []byte("second"), 0o644)) + secondTime := mustParseTestTime(t, "2026-06-28T13:00:00Z") + require.NoError(t, os.Chtimes(dbPath+"-shm", secondTime, secondTime)) + after, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + assert.Equal(t, before.Hash, after.Hash) + assert.Equal(t, before.Size, after.Size) + assert.Equal(t, before.MTimeNS, after.MTimeNS) +} + +func TestWindsurfProviderParsesTabContainerChatData(t *testing.T) { + root, _ := windsurfProviderFixture(t, `{ + "tabs": [{ + "tabId": "tab-session", + "chatTitle": "Tab title", + "bubbles": [ + {"type": "user", "text": "Question from tab"}, + {"type": "assistant", "text": "Answer from tab"} + ] + }] + }`) + provider := newTestWindsurfProvider(root) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + out, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + }) + require.NoError(t, err) + require.Len(t, out.Results, 1) + result := out.Results[0].Result + assert.Equal(t, "windsurf:tab-session", result.Session.ID) + require.Len(t, result.Messages, 2) + assert.Equal(t, "Question from tab", result.Messages[0].Content) + assert.Equal(t, "Answer from tab", result.Messages[1].Content) +} + +func TestWindsurfProviderMalformedChatDataReturnsError(t *testing.T) { + root, dbPath := windsurfProviderFixture(t, `{"tabs":`) + provider := newTestWindsurfProvider(root) + + _, err := provider.Parse(context.Background(), ParseRequest{ + Source: SourceRef{ + Provider: AgentWindsurf, + DisplayPath: dbPath + "#corrupt-session", + FingerprintKey: dbPath + "#corrupt-session", + }, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "parse windsurf chatdata") +} + +func TestWindsurfProviderParsesNumericTabBubbleTypes(t *testing.T) { + root, _ := windsurfProviderFixture(t, `{ + "tabs": [{ + "tabId": "numeric-tab-session", + "chatTitle": "Numeric tab", + "bubbles": [ + {"type": 1, "text": "Numeric question"}, + {"type": 2, "text": "Numeric answer"} + ] + }] + }`) + provider := newTestWindsurfProvider(root) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + out, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + }) + require.NoError(t, err) + require.Len(t, out.Results, 1) + messages := out.Results[0].Result.Messages + require.Len(t, messages, 2) + assert.Equal(t, RoleUser, messages[0].Role) + assert.Equal(t, "Numeric question", messages[0].Content) + assert.Equal(t, RoleAssistant, messages[1].Role) + assert.Equal(t, "Numeric answer", messages[1].Content) +} + +func TestWindsurfProviderFallbackSessionIDUsesWorkspaceIdentity(t *testing.T) { + root := filepath.Join(t.TempDir(), "Windsurf", "User") + dbA := filepath.Join(root, "workspaceStorage", "workspace-a", "state.vscdb") + dbB := filepath.Join(root, "workspaceStorage", "workspace-b", "state.vscdb") + payload := `{ + "version": 1, + "requests": [{ + "requestId": "request-1", + "message": {"text": "Missing session"}, + "response": [{"value": "Fallback response"}], + "timestamp": 1710000000000 + }] + }` + writeSourceFile(t, filepath.Join(filepath.Dir(dbA), "workspace.json"), `{"folder":"file:///work/a"}`) + writeSourceFile(t, filepath.Join(filepath.Dir(dbB), "workspace.json"), `{"folder":"file:///work/b"}`) + writeWindsurfStateDB(t, dbA, payload) + writeWindsurfStateDB(t, dbB, payload) + provider := newTestWindsurfProvider(root) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 2) + assert.ElementsMatch(t, []string{ + dbA + "#workspace-workspace-a", + dbB + "#workspace-workspace-b", + }, []string{sources[0].DisplayPath, sources[1].DisplayPath}) + + var ids []string + for _, source := range sources { + out, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + }) + require.NoError(t, err) + require.Len(t, out.Results, 1) + ids = append(ids, out.Results[0].Result.Session.ID) + } + assert.ElementsMatch(t, []string{ + "windsurf:workspace-workspace-a", + "windsurf:workspace-workspace-b", + }, ids) +} + +func TestWindsurfProviderFindSourceAndChangedPath(t *testing.T) { + root, dbPath := windsurfProviderFixture(t, windsurfVSCodeSessionJSON( + "windsurf-session-lookup", + "Find me", + "Found.", + )) + provider := newTestWindsurfProvider(root) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "windsurf-session-lookup", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, dbPath+"#windsurf-session-lookup", found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "windsurf:windsurf-session-lookup", + StoredFilePath: dbPath + "#windsurf-session-lookup", + RequireFreshSource: true, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, dbPath+"#windsurf-session-lookup", found.DisplayPath) + + changed, err := provider.SourcesForChangedPath(context.Background(), ChangedPathRequest{ + Path: dbPath + "-wal", + EventKind: "write", + WatchRoot: filepath.Join( + root, + "workspaceStorage", + ), + }) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, dbPath+"#windsurf-session-lookup", changed[0].DisplayPath) + + manifestChanged, err := provider.SourcesForChangedPath(context.Background(), ChangedPathRequest{ + Path: filepath.Join( + filepath.Dir(dbPath), + "workspace.json", + ), + EventKind: "write", + WatchRoot: filepath.Join( + root, + "workspaceStorage", + ), + }) + require.NoError(t, err) + require.Len(t, manifestChanged, 1) + assert.Equal(t, dbPath+"#windsurf-session-lookup", manifestChanged[0].DisplayPath) +} + +func TestWindsurfProviderDeletedDBChangedPathPreservesStoredArchive(t *testing.T) { + root, dbPath := windsurfProviderFixture(t, windsurfVSCodeSessionJSON( + "deleted-session", + "Question before delete", + "Answer before delete.", + )) + provider := newTestWindsurfProvider(root) + source := SourceRef{ + Provider: AgentWindsurf, + Key: dbPath + "#deleted-session", + DisplayPath: dbPath + "#deleted-session", + FingerprintKey: dbPath + "#deleted-session", + } + require.NoError(t, os.Remove(dbPath)) + + changed, err := provider.SourcesForChangedPath(context.Background(), ChangedPathRequest{ + Path: dbPath, + EventKind: "remove", + WatchRoot: filepath.Join(root, "workspaceStorage"), + StoredSourcePaths: []string{source.DisplayPath}, + }) + require.NoError(t, err) + assert.Empty(t, changed) + + fp, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + assert.Equal(t, source.FingerprintKey, fp.Key) + assert.Empty(t, fp.Hash) + + out, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + }) + require.NoError(t, err) + assert.True(t, out.ResultSetComplete) + assert.False(t, out.ForceReplace) + assert.Equal(t, SkipNoSession, out.SkipReason) +} + +func TestSplitWindsurfVirtualPathRequiresStateDB(t *testing.T) { + dbPath, sessionID, ok := SplitWindsurfVirtualPath( + filepath.Join("profile", "workspaceStorage", "hash", "state.vscdb") + "#session", + ) + require.True(t, ok) + assert.Equal(t, "session", sessionID) + assert.True(t, strings.HasSuffix(filepath.ToSlash(dbPath), "/state.vscdb")) + + _, _, ok = SplitWindsurfVirtualPath( + filepath.Join("profile", "notes#draft.json") + "#session", + ) + assert.False(t, ok) +} + +func TestWindsurfProviderWatchPlan(t *testing.T) { + provider := newTestWindsurfProvider(filepath.Join(t.TempDir(), "Windsurf", "User")) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 1) + root := plan.Roots[0] + assert.True(t, root.Recursive) + assert.True(t, strings.HasSuffix(filepath.ToSlash(root.Path), "/workspaceStorage")) + assert.Contains(t, root.IncludeGlobs, "state.vscdb") + assert.Contains(t, root.IncludeGlobs, "state.vscdb-wal") + assert.NotContains(t, root.IncludeGlobs, "state.vscdb-*") + assert.NotContains(t, root.IncludeGlobs, "state.vscdb-shm") + assert.Contains(t, root.IncludeGlobs, "workspace.json") +} + +func TestWindsurfProviderAcceptsWorkspaceStorageRoot(t *testing.T) { + root, dbPath := windsurfProviderFixture(t, windsurfVSCodeSessionJSON( + "workspace-root-session", + "Root question", + "Root answer.", + )) + provider := newTestWindsurfProvider(filepath.Join(root, "workspaceStorage")) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, dbPath+"#workspace-root-session", sources[0].DisplayPath) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 1) + assert.Equal(t, filepath.Join(root, "workspaceStorage"), plan.Roots[0].Path) +} + +func TestWriteWindsurfSessionJSONScopesVirtualSource(t *testing.T) { + _, dbPath := windsurfProviderFixture(t, `{ + "tabs": [ + {"tabId": "export-a", "bubbles": [{"type": "user", "text": "A only"}]}, + {"tabId": "export-b", "bubbles": [{"type": "user", "text": "B hidden"}]} + ] + }`) + var buf bytes.Buffer + + require.NoError(t, WriteWindsurfSessionJSON(&buf, dbPath, "export-a")) + + assert.Contains(t, buf.String(), "export-a") + assert.Contains(t, buf.String(), "A only") + assert.NotContains(t, buf.String(), "export-b") + assert.NotContains(t, buf.String(), "B hidden") +} + +func TestWriteSanitizedWindsurfStateDBCopiesOnlyChatKeys(t *testing.T) { + _, dbPath := windsurfProviderFixture(t, windsurfVSCodeSessionJSON( + "sanitized-export", + "Export chat", + "Do not export secrets.", + )) + insertWindsurfStateRow(t, dbPath, "extension.secret", "TOP-SECRET") + outPath := filepath.Join(t.TempDir(), "state.vscdb") + + require.NoError(t, WriteSanitizedWindsurfStateDB(outPath, dbPath)) + + conn, err := sql.Open("sqlite3", outPath) + require.NoError(t, err) + defer conn.Close() + rows, err := conn.Query(`SELECT key, value FROM ItemTable ORDER BY key`) + require.NoError(t, err) + defer rows.Close() + got := make(map[string]string) + for rows.Next() { + var key, value string + require.NoError(t, rows.Scan(&key, &value)) + got[key] = value + } + require.NoError(t, rows.Err()) + assert.Contains(t, got, "workbench.panel.aichat.view.aichat.chatdata") + assert.Contains(t, got["workbench.panel.aichat.view.aichat.chatdata"], "Export chat") + assert.NotContains(t, got, "extension.secret") +} + +func newTestWindsurfProvider(root string) Provider { + provider, ok := NewProvider(AgentWindsurf, ProviderConfig{ + Roots: []string{root}, + }) + if !ok { + panic("missing Windsurf provider") + } + return provider +} + +func windsurfProviderFixture(t *testing.T, payload string) (string, string) { + t.Helper() + root := filepath.Join(t.TempDir(), "Windsurf", "User") + workspaceDir := filepath.Join(root, "workspaceStorage", "workspace-hash") + writeSourceFile(t, filepath.Join(workspaceDir, "workspace.json"), `{"folder":"file:///work/demo-workspace"}`) + dbPath := filepath.Join(workspaceDir, "state.vscdb") + writeWindsurfStateDB(t, dbPath, payload) + return root, dbPath +} + +func writeWindsurfStateDB(t *testing.T, dbPath, payload string) { + t.Helper() + conn, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Exec(`CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)`) + require.NoError(t, err) + _, err = conn.Exec( + `INSERT INTO ItemTable (key, value) VALUES (?, ?)`, + "workbench.panel.aichat.view.aichat.chatdata", + payload, + ) + require.NoError(t, err) +} + +func insertWindsurfStateRow(t *testing.T, dbPath, key, value string) { + t.Helper() + conn, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Exec( + `INSERT INTO ItemTable (key, value) VALUES (?, ?)`, + key, + value, + ) + require.NoError(t, err) +} + +func mustParseTestTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, value) + require.NoError(t, err) + return parsed +} + +func windsurfVSCodeSessionJSON(sessionID, user, assistant string) string { + return `{ + "version": 1, + "sessionId": "` + sessionID + `", + "creationDate": 1710000000000, + "lastMessageDate": 1710000001000, + "requests": [{ + "requestId": "request-1", + "message": {"text": "` + user + `"}, + "response": [{"value": "` + assistant + `"}], + "timestamp": 1710000000000 + }] + }` +} diff --git a/internal/remotesync/archive.go b/internal/remotesync/archive.go index 001d32f91..b6c1971ae 100644 --- a/internal/remotesync/archive.go +++ b/internal/remotesync/archive.go @@ -7,17 +7,36 @@ import ( "io/fs" "os" "path/filepath" + "time" + + "go.kenn.io/agentsview/internal/parser" ) func WriteArchive(w io.Writer, targets TargetSet) error { tw := tar.NewWriter(w) - for _, dirs := range targets.Dirs { + for agent, dirs := range targets.Dirs { + if _, fileScoped := targets.Files[agent]; fileScoped { + continue + } for _, root := range dirs { if err := writeArchivePath(tw, root); err != nil { return err } } } + for agent, files := range targets.Files { + if agent == parser.AgentWindsurf { + if err := writeWindsurfArchiveFiles(tw, files); err != nil { + return err + } + continue + } + for _, path := range files { + if err := writeArchivePath(tw, path); err != nil { + return err + } + } + } for _, path := range targets.ExtraFiles { if err := writeArchivePath(tw, path); err != nil { return err @@ -29,6 +48,97 @@ func WriteArchive(w io.Writer, targets TargetSet) error { return nil } +func writeWindsurfArchiveFiles(tw *tar.Writer, files []string) error { + seen := make(map[string]struct{}, len(files)) + for _, path := range files { + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + switch filepath.Base(path) { + case parser.WindsurfStateDBName: + if err := writeSanitizedWindsurfStateDB(tw, path); err != nil { + return err + } + case parser.WindsurfStateDBName + "-wal", + parser.WindsurfStateDBName + "-shm": + continue + case "workspace.json": + if err := writeOptionalArchivePath(tw, path); err != nil { + return err + } + default: + continue + } + } + return nil +} + +func writeSanitizedWindsurfStateDB(tw *tar.Writer, dbPath string) error { + info, err := os.Stat(dbPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("stat windsurf state db %q: %w", dbPath, err) + } + if !info.Mode().IsRegular() { + return nil + } + tmpDir, err := os.MkdirTemp("", "agentsview-windsurf-export-*") + if err != nil { + return fmt.Errorf("create windsurf export temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + tmpPath := filepath.Join(tmpDir, parser.WindsurfStateDBName) + if err := parser.WriteSanitizedWindsurfStateDB(tmpPath, dbPath); err != nil { + return fmt.Errorf("sanitize windsurf state db %q: %w", dbPath, err) + } + mtime := windsurfArchiveModTime(info, dbPath) + if err := os.Chtimes(tmpPath, mtime, mtime); err != nil { + return fmt.Errorf("stamp sanitized windsurf state db: %w", err) + } + tmpInfo, err := os.Stat(tmpPath) + if err != nil { + return fmt.Errorf("stat sanitized windsurf state db: %w", err) + } + return writeArchiveFileAs(tw, dbPath, tmpPath, tmpInfo) +} + +func windsurfArchiveModTime(info os.FileInfo, dbPath string) time.Time { + mtime := info.ModTime() + for _, companion := range []string{ + dbPath + "-wal", + filepath.Join(filepath.Dir(dbPath), "workspace.json"), + } { + companionInfo, err := os.Stat(companion) + if err != nil { + continue + } + if companionInfo.ModTime().After(mtime) { + mtime = companionInfo.ModTime() + } + } + return mtime +} + +func writeOptionalArchivePath(tw *tar.Writer, path string) error { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("stat archive path %q: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + if !info.IsDir() { + return writeArchiveFile(tw, path, info) + } + return writeArchivePath(tw, path) +} + func writeArchivePath(tw *tar.Writer, root string) error { info, err := os.Lstat(root) if err != nil { @@ -93,6 +203,40 @@ func writeArchiveFile(tw *tar.Writer, path string, info os.FileInfo) error { return nil } +func writeArchiveFileAs( + tw *tar.Writer, + archivePath string, + bodyPath string, + info os.FileInfo, +) error { + if !info.Mode().IsRegular() { + return nil + } + file, err := os.Open(bodyPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer file.Close() + info, err = file.Stat() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !info.Mode().IsRegular() { + return nil + } + body := io.LimitReader(file, info.Size()) + if err := writeArchiveHeader(tw, archivePath, info, body); err != nil { + return err + } + return nil +} + func writeArchiveHeader( tw *tar.Writer, path string, diff --git a/internal/remotesync/paths.go b/internal/remotesync/paths.go index 920989c40..b1edf07ad 100644 --- a/internal/remotesync/paths.go +++ b/internal/remotesync/paths.go @@ -112,6 +112,13 @@ func validateTargetSetPaths(targets TargetSet) error { } } } + for agent, files := range targets.Files { + for _, file := range files { + if _, err := safeRemotePathArchiveName(file); err != nil { + return fmt.Errorf("target file %s %q: %w", agent, file, err) + } + } + } for _, file := range targets.ExtraFiles { if _, err := safeRemotePathArchiveName(file); err != nil { return fmt.Errorf("target file %q: %w", file, err) diff --git a/internal/remotesync/resolve.go b/internal/remotesync/resolve.go index 06dca9fb0..c05e25c7f 100644 --- a/internal/remotesync/resolve.go +++ b/internal/remotesync/resolve.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "slices" + "sort" "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/parser" @@ -12,6 +13,7 @@ import ( func ResolveTargets(cfg config.Config) TargetSet { dirs := make(map[parser.AgentType][]string) + files := make(map[parser.AgentType][]string) var extra []string for _, def := range parser.Registry { if !resolveAgentHasOnDiskSource(def) { @@ -25,6 +27,14 @@ func ResolveTargets(cfg config.Config) TargetSet { } continue } + if def.Type == parser.AgentWindsurf { + root, targetFiles := resolveWindsurfTarget(dir) + if root != "" && len(targetFiles) > 0 { + dirs[def.Type] = append(dirs[def.Type], root) + files[def.Type] = append(files[def.Type], targetFiles...) + } + continue + } if info, err := os.Stat(dir); err != nil || !info.IsDir() { continue } @@ -39,7 +49,7 @@ func ResolveTargets(cfg config.Config) TargetSet { } } } - return TargetSet{Dirs: dirs, ExtraFiles: extra} + return TargetSet{Dirs: dirs, Files: files, ExtraFiles: extra} } func resolveAgentHasOnDiskSource(def parser.AgentDef) bool { @@ -79,6 +89,64 @@ func resolveAiderTargets(root string) []string { return out } +func resolveWindsurfTarget(root string) (string, []string) { + targetRoot := filepath.Clean(root) + workspaceRoot := windsurfRemoteWorkspaceRoot(targetRoot) + if info, err := os.Stat(workspaceRoot); err != nil || !info.IsDir() { + return "", nil + } + files := resolveWindsurfFiles(workspaceRoot) + if len(files) == 0 { + return "", nil + } + return targetRoot, files +} + +func windsurfRemoteWorkspaceRoot(root string) string { + clean := filepath.Clean(root) + if filepath.Base(clean) == "workspaceStorage" { + return clean + } + return filepath.Join(clean, "workspaceStorage") +} + +func resolveWindsurfFiles(workspaceRoot string) []string { + entries, err := os.ReadDir(workspaceRoot) + if err != nil { + return nil + } + var files []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + workspaceDir := filepath.Join(workspaceRoot, entry.Name()) + dbPath := filepath.Join(workspaceDir, parser.WindsurfStateDBName) + if !regularRemoteSyncFile(dbPath) { + continue + } + files = append(files, dbPath) + for _, path := range []string{ + dbPath + "-wal", + filepath.Join(workspaceDir, "workspace.json"), + } { + if regularRemoteSyncFile(path) { + files = append(files, path) + } + } + } + sort.Strings(files) + return files +} + +func regularRemoteSyncFile(path string) bool { + info, err := os.Lstat(path) + if err != nil { + return false + } + return info.Mode().IsRegular() +} + func providerDiscoveredPath(source parser.SourceRef) string { for _, path := range []string{ source.DisplayPath, @@ -103,6 +171,12 @@ func SelectAllowedTargets(allowed TargetSet, requested TargetSet) (TargetSet, bo } for agent, dirs := range requested.Dirs { allowedDirs := allowed.Dirs[agent] + if _, fileScoped := allowed.Files[agent]; fileScoped { + requestedFiles, ok := requested.Files[agent] + if !ok || len(requestedFiles) == 0 { + return TargetSet{}, false + } + } for _, dir := range dirs { selectedDir, ok := selectAllowedString(allowedDirs, dir) if !ok { @@ -111,6 +185,22 @@ func SelectAllowedTargets(allowed TargetSet, requested TargetSet) (TargetSet, bo selected.Dirs[agent] = append(selected.Dirs[agent], selectedDir) } } + for agent, files := range requested.Files { + allowedFiles, ok := allowed.Files[agent] + if !ok { + return TargetSet{}, false + } + for _, file := range files { + selectedFile, ok := selectAllowedString(allowedFiles, file) + if !ok { + return TargetSet{}, false + } + if selected.Files == nil { + selected.Files = make(map[parser.AgentType][]string) + } + selected.Files[agent] = append(selected.Files[agent], selectedFile) + } + } for _, file := range requested.ExtraFiles { selectedFile, ok := selectAllowedString(allowed.ExtraFiles, file) if !ok { diff --git a/internal/remotesync/resolve_test.go b/internal/remotesync/resolve_test.go index 8053dd1ae..9fc99a7a0 100644 --- a/internal/remotesync/resolve_test.go +++ b/internal/remotesync/resolve_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -24,12 +25,26 @@ func TestResolveTargetsFiltersAndIncludesSpecialFiles(t *testing.T) { warpDir := filepath.Join(root, "warp") aiderRoot := filepath.Join(root, "code") aiderHistory := filepath.Join(aiderRoot, "repo", parser.AiderHistoryFileName()) + windsurfUserRoot := filepath.Join(root, "Windsurf", "User") + windsurfWorkspaceRoot := filepath.Join(windsurfUserRoot, "workspaceStorage") + windsurfWorkspaceDir := filepath.Join(windsurfWorkspaceRoot, "workspace-a") + windsurfStateDB := filepath.Join(windsurfWorkspaceDir, parser.WindsurfStateDBName) + windsurfStateWAL := windsurfStateDB + "-wal" + windsurfStateSHM := windsurfStateDB + "-shm" + windsurfWorkspaceJSON := filepath.Join(windsurfWorkspaceDir, "workspace.json") + windsurfSecret := filepath.Join(windsurfWorkspaceDir, "extension-secret.json") require.NoError(t, os.MkdirAll(claudeDir, 0o755)) require.NoError(t, os.MkdirAll(codexDir, 0o755)) require.NoError(t, os.MkdirAll(devinDir, 0o755)) require.NoError(t, os.MkdirAll(warpDir, 0o755)) require.NoError(t, os.MkdirAll(filepath.Dir(aiderHistory), 0o755)) + require.NoError(t, os.MkdirAll(windsurfWorkspaceDir, 0o755)) require.NoError(t, os.WriteFile(aiderHistory, []byte("# aider\n"), 0o644)) + require.NoError(t, os.WriteFile(windsurfStateDB, []byte("state"), 0o644)) + require.NoError(t, os.WriteFile(windsurfStateWAL, []byte("wal"), 0o644)) + require.NoError(t, os.WriteFile(windsurfStateSHM, []byte("shm"), 0o644)) + require.NoError(t, os.WriteFile(windsurfWorkspaceJSON, []byte("{}\n"), 0o644)) + require.NoError(t, os.WriteFile(windsurfSecret, []byte("secret"), 0o644)) codexIndex := filepath.Join(root, ".codex", parser.CodexSessionIndexFilename) require.NoError(t, os.WriteFile(codexIndex, []byte("{}\n"), 0o644)) @@ -41,6 +56,9 @@ func TestResolveTargetsFiltersAndIncludesSpecialFiles(t *testing.T) { parser.AgentWarp: {warpDir}, parser.AgentAider: {aiderRoot}, parser.AgentZed: {filepath.Join(root, "zed")}, + parser.AgentWindsurf: { + windsurfUserRoot, + }, }, }) @@ -50,6 +68,15 @@ func TestResolveTargetsFiltersAndIncludesSpecialFiles(t *testing.T) { assert.NotContains(t, targets.Dirs, parser.AgentWarp) assert.Equal(t, []string{aiderHistory}, targets.Dirs[parser.AgentAider]) assert.NotContains(t, targets.Dirs, parser.AgentZed) + assert.Equal(t, []string{windsurfUserRoot}, targets.Dirs[parser.AgentWindsurf]) + assert.NotContains(t, targets.Dirs[parser.AgentWindsurf], windsurfWorkspaceRoot) + assert.ElementsMatch(t, []string{ + windsurfStateDB, + windsurfStateWAL, + windsurfWorkspaceJSON, + }, targets.Files[parser.AgentWindsurf]) + assert.NotContains(t, targets.Files[parser.AgentWindsurf], windsurfStateSHM) + assert.NotContains(t, targets.Files[parser.AgentWindsurf], windsurfSecret) assert.Contains(t, targets.ExtraFiles, codexIndex) } @@ -75,13 +102,26 @@ func TestResolveTargetsSkipsAiderHomeRoot(t *testing.T) { func TestSelectAllowedTargetsReturnsResolvedValues(t *testing.T) { allowed := remotesync.TargetSet{ Dirs: map[parser.AgentType][]string{ - parser.AgentClaude: {"/srv/claude", "/srv/claude-extra"}, + parser.AgentClaude: {"/srv/claude", "/srv/claude-extra"}, + parser.AgentWindsurf: {"/srv/Windsurf/User"}, + }, + Files: map[parser.AgentType][]string{ + parser.AgentWindsurf: { + "/srv/Windsurf/User/workspaceStorage/a/state.vscdb", + "/srv/Windsurf/User/workspaceStorage/a/workspace.json", + }, }, ExtraFiles: []string{"/srv/.codex/session_index.jsonl"}, } requested := remotesync.TargetSet{ Dirs: map[parser.AgentType][]string{ - parser.AgentClaude: {"/srv/claude-extra"}, + parser.AgentClaude: {"/srv/claude-extra"}, + parser.AgentWindsurf: {"/srv/Windsurf/User"}, + }, + Files: map[parser.AgentType][]string{ + parser.AgentWindsurf: { + "/srv/Windsurf/User/workspaceStorage/a/state.vscdb", + }, }, ExtraFiles: []string{"/srv/.codex/session_index.jsonl"}, } @@ -90,9 +130,36 @@ func TestSelectAllowedTargetsReturnsResolvedValues(t *testing.T) { require.True(t, ok) assert.Equal(t, []string{"/srv/claude-extra"}, selected.Dirs[parser.AgentClaude]) + assert.Equal(t, []string{"/srv/Windsurf/User"}, selected.Dirs[parser.AgentWindsurf]) + assert.Equal(t, []string{ + "/srv/Windsurf/User/workspaceStorage/a/state.vscdb", + }, selected.Files[parser.AgentWindsurf]) assert.Equal(t, []string{"/srv/.codex/session_index.jsonl"}, selected.ExtraFiles) } +func TestSelectAllowedTargetsRejectsFileScopedDirOnlyRequest(t *testing.T) { + allowed := remotesync.TargetSet{ + Dirs: map[parser.AgentType][]string{ + parser.AgentWindsurf: {"/srv/Windsurf/User"}, + }, + Files: map[parser.AgentType][]string{ + parser.AgentWindsurf: { + "/srv/Windsurf/User/workspaceStorage/a/state.vscdb", + }, + }, + } + requested := remotesync.TargetSet{ + Dirs: map[parser.AgentType][]string{ + parser.AgentWindsurf: {"/srv/Windsurf/User"}, + }, + } + + _, ok := remotesync.SelectAllowedTargets(allowed, requested) + + assert.False(t, ok) + assert.False(t, remotesync.TargetSetAllowed(allowed, requested)) +} + func TestSelectAllowedTargetsRejectsUnresolvedValues(t *testing.T) { allowed := remotesync.TargetSet{ Dirs: map[parser.AgentType][]string{ @@ -121,19 +188,28 @@ func TestResolveTargetsMatchesSSHResolverForRepresentativeHome(t *testing.T) { devinDir := filepath.Join(home, ".local", "share", "devin") aiderRoot := filepath.Join(home, "code") aiderHistory := filepath.Join(aiderRoot, "repo", parser.AiderHistoryFileName()) + windsurfUserRoot := filepath.Join(home, "AppData", "Roaming", "Windsurf", "User") + windsurfWorkspaceRoot := filepath.Join(windsurfUserRoot, "workspaceStorage") + windsurfWorkspaceDir := filepath.Join(windsurfWorkspaceRoot, "workspace-a") + windsurfStateDB := filepath.Join(windsurfWorkspaceDir, parser.WindsurfStateDBName) + windsurfWorkspaceJSON := filepath.Join(windsurfWorkspaceDir, "workspace.json") require.NoError(t, os.MkdirAll(claudeDir, 0o755)) require.NoError(t, os.MkdirAll(codexDir, 0o755)) require.NoError(t, os.MkdirAll(devinDir, 0o755)) require.NoError(t, os.MkdirAll(filepath.Dir(aiderHistory), 0o755)) + require.NoError(t, os.MkdirAll(windsurfWorkspaceDir, 0o755)) require.NoError(t, os.WriteFile(aiderHistory, []byte("# aider\n"), 0o644)) + require.NoError(t, os.WriteFile(windsurfStateDB, []byte("state"), 0o644)) + require.NoError(t, os.WriteFile(windsurfWorkspaceJSON, []byte("{}\n"), 0o644)) codexIndex := filepath.Join(home, ".codex", parser.CodexSessionIndexFilename) require.NoError(t, os.WriteFile(codexIndex, []byte("{}\n"), 0o644)) - cmd := exec.Command("sh", "-c", ssh.BuildResolveScriptForTest()) + cmd := exec.Command("sh") + cmd.Stdin = strings.NewReader(ssh.BuildResolveScriptForTest()) cmd.Env = []string{"HOME=" + home, "AIDER_DIR=" + aiderRoot, "DEVIN_DIR=" + devinDir} out, err := cmd.CombinedOutput() require.NoError(t, err, "ssh resolver output: %s", out) - sshDirs, sshExtra := ssh.ParseResolvedTargetsForTest(string(out)) + sshDirs, sshFiles, sshExtra := ssh.ParseResolvedTargetsWithFilesForTest(string(out)) goTargets := remotesync.ResolveTargets(config.Config{ AgentDirs: map[parser.AgentType][]string{ @@ -141,6 +217,9 @@ func TestResolveTargetsMatchesSSHResolverForRepresentativeHome(t *testing.T) { parser.AgentCodex: {codexDir}, parser.AgentDevin: {devinDir}, parser.AgentAider: {aiderRoot}, + parser.AgentWindsurf: { + windsurfUserRoot, + }, }, }) assert.ElementsMatch(t, sshDirs[parser.AgentClaude], goTargets.Dirs[parser.AgentClaude]) @@ -148,5 +227,17 @@ func TestResolveTargetsMatchesSSHResolverForRepresentativeHome(t *testing.T) { assert.NotContains(t, sshDirs, parser.AgentDevin) assert.NotContains(t, goTargets.Dirs, parser.AgentDevin) assert.ElementsMatch(t, sshDirs[parser.AgentAider], goTargets.Dirs[parser.AgentAider]) + assert.ElementsMatch(t, []string{windsurfUserRoot}, sshDirs[parser.AgentWindsurf]) + assert.ElementsMatch(t, sshDirs[parser.AgentWindsurf], goTargets.Dirs[parser.AgentWindsurf]) + assert.ElementsMatch(t, []string{ + windsurfStateDB, + windsurfWorkspaceJSON, + }, sshFiles[parser.AgentWindsurf]) + assert.ElementsMatch(t, []string{ + windsurfStateDB, + windsurfWorkspaceJSON, + }, goTargets.Files[parser.AgentWindsurf]) + assert.ElementsMatch(t, sshFiles[parser.AgentWindsurf], goTargets.Files[parser.AgentWindsurf]) + assert.NotContains(t, sshDirs[parser.AgentWindsurf], windsurfWorkspaceRoot) assert.ElementsMatch(t, sshExtra, goTargets.ExtraFiles) } diff --git a/internal/remotesync/types.go b/internal/remotesync/types.go index 068357dbd..68f1de334 100644 --- a/internal/remotesync/types.go +++ b/internal/remotesync/types.go @@ -15,6 +15,7 @@ type SyncStats struct { type TargetSet struct { Dirs map[parser.AgentType][]string `json:"dirs"` + Files map[parser.AgentType][]string `json:"files,omitempty"` ExtraFiles []string `json:"extra_files,omitempty"` } diff --git a/internal/server/huma_routes_remote_sync_internal_test.go b/internal/server/huma_routes_remote_sync_internal_test.go index d0fa2b875..457745b79 100644 --- a/internal/server/huma_routes_remote_sync_internal_test.go +++ b/internal/server/huma_routes_remote_sync_internal_test.go @@ -3,6 +3,7 @@ package server import ( "archive/tar" "bytes" + "database/sql" "encoding/json" "errors" "io" @@ -14,11 +15,13 @@ import ( "testing" "time" + _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/dbtest" "go.kenn.io/agentsview/internal/parser" + "go.kenn.io/agentsview/internal/remotesync" ) func newRemoteSyncServer(t *testing.T) (*Server, http.Handler, string) { @@ -103,6 +106,64 @@ func TestRemoteSyncArchiveStreamsTar(t *testing.T) { } } +func TestRemoteSyncArchiveWindsurfStreamsSanitizedStateDB(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + database := dbtest.OpenTestDBAt(t, dbPath) + windsurfRoot := filepath.Join(dir, "Windsurf", "User") + workspaceDir := filepath.Join(windsurfRoot, "workspaceStorage", "workspace-a") + stateDB := filepath.Join(workspaceDir, parser.WindsurfStateDBName) + workspaceJSON := filepath.Join(workspaceDir, "workspace.json") + secretPath := filepath.Join(workspaceDir, "extension-secret.json") + require.NoError(t, os.MkdirAll(workspaceDir, 0o755)) + closeStateDB := writeWindsurfArchiveStateDB(t, stateDB) + defer closeStateDB() + require.NoError(t, os.WriteFile(workspaceJSON, []byte(`{"folder":"file:///work/demo"}`), 0o644)) + require.NoError(t, os.WriteFile(secretPath, []byte("do not archive"), 0o644)) + srv := New(config.Config{ + Host: "127.0.0.1", + Port: 8080, + DataDir: dir, + DBPath: dbPath, + AuthToken: "remote-token", + RequireAuth: false, + WriteTimeout: 30 * time.Second, + AgentDirs: map[parser.AgentType][]string{ + parser.AgentWindsurf: {windsurfRoot}, + }, + }, database, nil) + handler := srv.Handler() + + targetReq := httptest.NewRequest(http.MethodGet, "/api/v1/remote-sync/targets", nil) + targetReq.Header.Set("Authorization", "Bearer remote-token") + targetW := httptest.NewRecorder() + handler.ServeHTTP(targetW, targetReq) + require.Equal(t, http.StatusOK, targetW.Code, "body: %s", targetW.Body.String()) + var targets remotesync.TargetSet + require.NoError(t, json.Unmarshal(targetW.Body.Bytes(), &targets)) + payload, err := json.Marshal(targets) + require.NoError(t, err) + archiveReq := httptest.NewRequest(http.MethodPost, "/api/v1/remote-sync/archive", bytes.NewReader(payload)) + archiveReq.Header.Set("Authorization", "Bearer remote-token") + archiveReq.Header.Set("Content-Type", "application/json") + archiveW := httptest.NewRecorder() + + handler.ServeHTTP(archiveW, archiveReq) + + require.Equal(t, http.StatusOK, archiveW.Code, "body: %s", archiveW.Body.String()) + archiveBytes := archiveW.Body.Bytes() + assert.NotContains(t, string(archiveBytes), "extension secret value") + entries := tarEntries(t, archiveBytes) + names := tarEntryNames(entries) + stateEntry, ok := tarEntryWithSuffix(entries, "workspace-a/"+parser.WindsurfStateDBName) + require.True(t, ok, "entries: %v", names) + assert.True(t, hasTarEntrySuffix(entries, "workspace-a/workspace.json"), "entries: %v", entries) + assert.False(t, hasTarEntrySuffix(entries, "workspace-a/extension-secret.json"), "entries: %v", entries) + assert.False(t, hasTarEntrySuffix(entries, "workspace-a/"+parser.WindsurfStateDBName+"-wal"), "entries: %v", entries) + assert.False(t, hasTarEntrySuffix(entries, "workspace-a/"+parser.WindsurfStateDBName+"-shm"), "entries: %v", entries) + assertSanitizedWindsurfArchiveDB(t, stateEntry.Body) +} + func pathBaseSlash(p string) string { i := strings.LastIndex(p, "/") if i < 0 { @@ -111,6 +172,101 @@ func pathBaseSlash(p string) string { return p[i+1:] } +type tarTestEntry struct { + Name string + Body []byte +} + +func tarEntries(t *testing.T, archive []byte) []tarTestEntry { + t.Helper() + tr := tar.NewReader(bytes.NewReader(archive)) + var entries []tarTestEntry + for { + hdr, err := tr.Next() + if err == io.EOF { + return entries + } + require.NoError(t, err) + body, err := io.ReadAll(tr) + require.NoError(t, err) + entries = append(entries, tarTestEntry{Name: hdr.Name, Body: body}) + } +} + +func tarEntryNames(entries []tarTestEntry) []string { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name) + } + return names +} + +func hasTarEntrySuffix(entries []tarTestEntry, suffix string) bool { + _, ok := tarEntryWithSuffix(entries, suffix) + return ok +} + +func tarEntryWithSuffix(entries []tarTestEntry, suffix string) (tarTestEntry, bool) { + for _, entry := range entries { + if strings.HasSuffix(entry.Name, suffix) { + return entry, true + } + } + return tarTestEntry{}, false +} + +func writeWindsurfArchiveStateDB(t *testing.T, dbPath string) func() { + t.Helper() + conn, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + conn.SetMaxOpenConns(1) + _, err = conn.Exec(`PRAGMA journal_mode=WAL`) + require.NoError(t, err) + _, err = conn.Exec(`CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)`) + require.NoError(t, err) + _, err = conn.Exec( + `INSERT INTO ItemTable (key, value) VALUES (?, ?)`, + "workbench.panel.aichat.view.aichat.chatdata", + `{"version":1,"sessionId":"remote-windsurf","requests":[{"requestId":"request-1","message":{"text":"Remote chat"},"response":[{"value":"Remote answer"}],"timestamp":1710000000000}]}`, + ) + require.NoError(t, err) + _, err = conn.Exec( + `INSERT INTO ItemTable (key, value) VALUES (?, ?)`, + "extension.secret", + "extension secret value", + ) + require.NoError(t, err) + require.FileExists(t, dbPath+"-wal") + return func() { + require.NoError(t, conn.Close()) + } +} + +func assertSanitizedWindsurfArchiveDB(t *testing.T, body []byte) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "state.vscdb") + require.NoError(t, os.WriteFile(dbPath, body, 0o644)) + conn, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + defer conn.Close() + rows, err := conn.Query(`SELECT key, value FROM ItemTable ORDER BY key`) + require.NoError(t, err) + defer rows.Close() + got := make(map[string]string) + for rows.Next() { + var key, value string + require.NoError(t, rows.Scan(&key, &value)) + got[key] = value + } + require.NoError(t, rows.Err()) + require.Len(t, got, 1) + for key, value := range got { + assert.Equal(t, "workbench.panel.aichat.view.aichat.chatdata", key) + assert.Contains(t, value, "Remote chat") + assert.NotContains(t, value, "extension secret value") + } +} + func TestRemoteSyncArchiveDoesNotAppendErrorAfterStreamingStarts(t *testing.T) { srv, _, sessionPath := newRemoteSyncServer(t) targets := map[string]any{ diff --git a/internal/service/direct.go b/internal/service/direct.go index a09024c0d..3520a5f25 100644 --- a/internal/service/direct.go +++ b/internal/service/direct.go @@ -412,6 +412,14 @@ func (b *directBackend) Sync( } return b.Get(ctx, in.ID) } + if _, _, ok := parser.SplitWindsurfVirtualPath(storedPath); ok { + if err := b.engine.SyncSingleSessionContext( + ctx, in.ID, + ); err != nil { + return nil, err + } + return b.Get(ctx, in.ID) + } path = parser.ResolveSourceFilePath(storedPath) } @@ -491,12 +499,12 @@ func (b *directBackend) resolveSessionIDByPath( WHERE file_path = ? ORDER BY created_at DESC` queryArgs := []any{path} - // Visual Studio Copilot stores file_path as a virtual sync key - // #, so an exact match on the physical - // container path never resolves. Also match every conversation - // synced from that container; multiple matches fall through to the - // ambiguity error below, exactly like a multi-session JSONL file. - if isVisualStudioCopilotVirtualContainerPath(path) { + // Some providers store file_path as a virtual sync key + // #, so an exact match on the physical container path + // never resolves. Also match every session synced from that container; + // multiple matches fall through to the ambiguity error below, exactly like a + // multi-session JSONL file. + if isVirtualSessionContainerPath(path) { q = `SELECT id FROM sessions WHERE file_path = ? OR file_path LIKE ? ESCAPE '\' ORDER BY created_at DESC` @@ -541,6 +549,11 @@ func (b *directBackend) resolveSessionIDByPath( } } +func isVirtualSessionContainerPath(path string) bool { + return isVisualStudioCopilotVirtualContainerPath(path) || + isWindsurfVirtualContainerPath(path) +} + func isVisualStudioCopilotVirtualContainerPath(path string) bool { if parser.IsVisualStudioCopilotTraceFile(path) { return true @@ -551,6 +564,13 @@ func isVisualStudioCopilotVirtualContainerPath(path string) bool { return ok } +func isWindsurfVirtualContainerPath(path string) bool { + _, _, ok := parser.SplitWindsurfVirtualPath( + parser.VirtualSourcePath(path, filepath.Base(path)), + ) + return ok +} + // Watch returns a stream of events for the given session, // emitting "session_updated" whenever the session's DB state // changes and periodic "heartbeat" events so callers can detect diff --git a/internal/service/direct_test.go b/internal/service/direct_test.go index cdddebeb8..395865186 100644 --- a/internal/service/direct_test.go +++ b/internal/service/direct_test.go @@ -797,6 +797,58 @@ func TestDirectBackend_Sync_VSCopilotPhysicalPathAmbiguous(t *testing.T) { assert.Contains(t, msg, "session sync ") } +func TestDirectBackend_Sync_WindsurfPhysicalDBPathResolvesSession(t *testing.T) { + t.Parallel() + d := dbtest.OpenTestDB(t) + engine := sync.NewEngine(d, sync.EngineConfig{Ephemeral: true}) + svc := service.NewDirectBackend(d, engine) + + dbPath := filepath.Join("/profile", "Windsurf", "User", "workspaceStorage", "hash", "state.vscdb") + virtual := parser.VirtualSourcePath(dbPath, "windsurf-session") + sessionID := "windsurf:windsurf-session" + require.NoError(t, d.UpsertSession(db.Session{ + ID: sessionID, + Project: "windsurf", + Machine: "local", + Agent: "windsurf", + FilePath: &virtual, + })) + + detail, err := svc.Sync(context.Background(), service.SyncInput{ + Path: dbPath, + }) + require.NoError(t, err) + require.NotNil(t, detail) + assert.Equal(t, sessionID, detail.ID) +} + +func TestDirectBackend_Sync_WindsurfPhysicalDBPathAmbiguous(t *testing.T) { + t.Parallel() + d := dbtest.OpenTestDB(t) + engine := sync.NewEngine(d, sync.EngineConfig{Ephemeral: true}) + svc := service.NewDirectBackend(d, engine) + + dbPath := filepath.Join("/profile", "Windsurf", "User", "workspaceStorage", "hash", "state.vscdb") + for _, sessionID := range []string{"windsurf:a", "windsurf:b"} { + virtual := parser.VirtualSourcePath(dbPath, strings.TrimPrefix(sessionID, "windsurf:")) + require.NoError(t, d.UpsertSession(db.Session{ + ID: sessionID, + Project: "windsurf", + Machine: "local", + Agent: "windsurf", + FilePath: &virtual, + })) + } + + _, err := svc.Sync(context.Background(), service.SyncInput{ + Path: dbPath, + }) + require.Error(t, err) + msg := err.Error() + assert.Contains(t, msg, "2 sessions found") + assert.Contains(t, msg, "session sync ") +} + func TestDirectBackend_Sync_VSCopilotIDRefreshesOnlyRequestedConversation(t *testing.T) { t.Parallel() tracesDir := t.TempDir() diff --git a/internal/ssh/resolve.go b/internal/ssh/resolve.go index ed26b2da8..718753f4a 100644 --- a/internal/ssh/resolve.go +++ b/internal/ssh/resolve.go @@ -14,6 +14,10 @@ import ( // is not a valid agent type, so parseResolvedDirs routes it separately. const resolveFilePrefix = "@file" +// resolveAgentFilePrefix marks lines that name an agent-scoped file to +// transfer without recursively archiving that agent's root directory. +const resolveAgentFilePrefix = "@agentfile" + const resolveRecordSep = "\x00" func aiderSkipDirCasePattern() string { @@ -74,17 +78,52 @@ func buildAiderResolveSnippet(envVar string) string { func buildResolveScript() string { var b strings.Builder b.WriteString( - "av_emit_dir() { " + + "av_emit_agent_file() { " + + "agent=\"$1\"; " + + "file=\"$2\"; " + + "[ -f \"$file\" ] && printf '%s\\000' \"" + resolveAgentFilePrefix + ":$agent:$file\"; " + + "}\n" + + "av_emit_windsurf_target() { " + + "target=\"$1\"; " + + "case \"$target\" in */) target=\"${target%/}\";; esac; " + + "workspace=\"$target\"; " + + "case \"$workspace\" in */workspaceStorage) ;; " + + "*) workspace=\"$workspace/workspaceStorage\";; esac; " + + "[ -d \"$workspace\" ] || return; " + + "av_windsurf_root_emitted=0; " + + "for av_windsurf_ws in \"$workspace\"/*; do " + + "[ -d \"$av_windsurf_ws\" ] || continue; " + + "av_windsurf_db=\"$av_windsurf_ws/" + parser.WindsurfStateDBName + "\"; " + + "[ -f \"$av_windsurf_db\" ] || continue; " + + "if [ \"$av_windsurf_root_emitted\" -eq 0 ]; then " + + "printf '%s\\000' \"" + string(parser.AgentWindsurf) + ":$target\"; " + + "av_windsurf_root_emitted=1; " + + "fi; " + + "for av_windsurf_file in \"$av_windsurf_db\" \"$av_windsurf_db-wal\" \"$av_windsurf_ws/workspace.json\"; do " + + "av_emit_agent_file \"" + string(parser.AgentWindsurf) + "\" \"$av_windsurf_file\"; " + + "done; " + + "done; " + + "}\n" + + "av_emit_target() { " + + "agent=\"$1\"; " + + "target=\"$2\"; " + + "if [ \"$agent\" = \"" + string(parser.AgentWindsurf) + "\" ]; then " + + "av_emit_windsurf_target \"$target\"; " + + "return; " + + "fi; " + + "[ -d \"$target\" ] && printf '%s\\000' \"$agent:$target\"; " + + "}\n" + + "av_emit_dir() { " + "dir=\"$1\"; " + "[ -n \"$dir\" ] || dir=\"$2\"; " + - "[ -d \"$dir\" ] && printf '%s\\000' \"$3:$dir\"; " + + "av_emit_target \"$3\" \"$dir\"; " + "}\n" + "av_emit_rooted_dir() { " + "dir=\"$1\"; " + "root=\"$2\"; " + "[ -z \"$dir\" ] && [ -n \"$root\" ] && dir=\"$root$3\"; " + "[ -n \"$dir\" ] || dir=\"$4\"; " + - "[ -d \"$dir\" ] && printf '%s\\000' \"$5:$dir\"; " + + "av_emit_target \"$5\" \"$dir\"; " + "}\n" + "av_emit_codex_index() { " + "idx=\"${dir%/*}/" + parser.CodexSessionIndexFilename + "\"; " + @@ -184,20 +223,22 @@ func resolveAgentHasOnDiskSource(def parser.AgentDef) bool { } } -// parseResolvedDirs parses script output into a map of agent type to transfer -// target paths plus a deduplicated list of extra files (records tagged with -// resolveFilePrefix). Generated resolver output is NUL-delimited so remote -// paths containing newlines cannot inject extra records; newline-delimited input -// is accepted only for older tests and defensive compatibility. Most agent -// targets are directories; Aider targets are individual .aider.chat.history.md -// files. Skips empty records, empty values, and values containing record -// separators. -func parseResolvedDirs( +// parseResolvedTargets parses script output into agent root paths, +// agent-scoped files, and a deduplicated list of extra files (records +// tagged with resolveFilePrefix). Generated resolver output is +// NUL-delimited so remote paths containing newlines cannot inject extra +// records; newline-delimited input is accepted only for older tests and +// defensive compatibility. Most agent targets are directories; Aider +// targets are individual .aider.chat.history.md files. Skips empty +// records, empty values, and values containing record separators. +func parseResolvedTargets( output string, -) (map[parser.AgentType][]string, []string) { +) (map[parser.AgentType][]string, map[parser.AgentType][]string, []string) { dirs := make(map[parser.AgentType][]string) + files := make(map[parser.AgentType][]string) var extraFiles []string seenFile := make(map[string]struct{}) + seenAgentFile := make(map[parser.AgentType]map[string]struct{}) for _, record := range resolveOutputRecords(output) { record = strings.TrimSpace(record) if record == "" { @@ -215,6 +256,27 @@ func parseResolvedDirs( extraFiles = append(extraFiles, value) continue } + if key == resolveAgentFilePrefix { + agent, pathValue, ok := strings.Cut(value, ":") + if !ok || invalidResolvedPath(pathValue) { + continue + } + at := parser.AgentType(agent) + if at == "" { + continue + } + seen, ok := seenAgentFile[at] + if !ok { + seen = make(map[string]struct{}) + seenAgentFile[at] = seen + } + if _, dup := seen[pathValue]; dup { + continue + } + seen[pathValue] = struct{}{} + files[at] = append(files[at], pathValue) + continue + } at := parser.AgentType(key) if at == parser.AgentAider && path.Base(value) != parser.AiderHistoryFileName() { @@ -222,6 +284,13 @@ func parseResolvedDirs( } dirs[at] = append(dirs[at], value) } + return dirs, files, extraFiles +} + +func parseResolvedDirs( + output string, +) (map[parser.AgentType][]string, []string) { + dirs, _, extraFiles := parseResolvedTargets(output) return dirs, extraFiles } @@ -231,6 +300,12 @@ func ParseResolvedTargetsForTest(output string) (map[parser.AgentType][]string, return parseResolvedDirs(output) } +func ParseResolvedTargetsWithFilesForTest( + output string, +) (map[parser.AgentType][]string, map[parser.AgentType][]string, []string) { + return parseResolvedTargets(output) +} + func resolveOutputRecords(output string) []string { if strings.Contains(output, resolveRecordSep) { return strings.Split(output, resolveRecordSep) @@ -248,12 +323,12 @@ func invalidResolvedPath(value string) bool { func resolveDirs( ctx context.Context, host, user string, port int, sshOpts []string, -) (map[parser.AgentType][]string, []string, error) { +) (map[parser.AgentType][]string, map[parser.AgentType][]string, []string, error) { script := buildResolveScript() - out, err := runSSH(ctx, host, user, port, sshOpts, script) + out, err := runSSHScript(ctx, host, user, port, sshOpts, script) if err != nil { - return nil, nil, fmt.Errorf("resolve dirs: %w", err) + return nil, nil, nil, fmt.Errorf("resolve dirs: %w", err) } - dirs, extraFiles := parseResolvedDirs(string(out)) - return dirs, extraFiles, nil + dirs, files, extraFiles := parseResolvedTargets(string(out)) + return dirs, files, extraFiles, nil } diff --git a/internal/ssh/resolve_test.go b/internal/ssh/resolve_test.go index c2d7c8ca3..c03695f17 100644 --- a/internal/ssh/resolve_test.go +++ b/internal/ssh/resolve_test.go @@ -46,11 +46,7 @@ func TestResolveScriptExcludesDevinProviderRoot(t *testing.T) { devinRoot := filepath.Join(home, ".local", "share", "devin") require.NoError(t, os.MkdirAll(devinRoot, 0o755)) - script := buildResolveScript() - cmd := exec.Command("sh", "-c", script) - cmd.Env = []string{"HOME=" + home, "DEVIN_DIR=" + devinRoot} - out, err := cmd.CombinedOutput() - require.NoError(t, err, "resolve script failed: output: %s", out) + out := runResolveScriptForTest(t, "HOME="+home, "DEVIN_DIR="+devinRoot) dirs, _ := parseResolvedDirs(string(out)) assert.NotContains(t, dirs, parser.AgentDevin) @@ -62,14 +58,10 @@ func TestResolveScriptHonorsClaudeConfigDirRoot(t *testing.T) { projectsDir := filepath.Join(root, "projects") require.NoError(t, os.MkdirAll(projectsDir, 0o755), "mkdir projects") - script := buildResolveScript() - cmd := exec.Command("sh", "-c", script) - cmd.Env = []string{ - "HOME=" + home, - "CLAUDE_CONFIG_DIR=" + root, - } - out, err := cmd.CombinedOutput() - require.NoError(t, err, "resolve script failed: output: %s", out) + out := runResolveScriptForTest(t, + "HOME="+home, + "CLAUDE_CONFIG_DIR="+root, + ) dirs, _ := parseResolvedDirs(string(out)) assert.Contains(t, dirs[parser.AgentClaude], root+"/projects") @@ -99,11 +91,7 @@ func TestResolveScriptExitsZero(t *testing.T) { // The resolve script must exit 0 even when no agent // dirs exist. Verify by running it against an empty // HOME so no default dirs are found. - script := buildResolveScript() - cmd := exec.Command("sh", "-c", script) - cmd.Env = []string{"HOME=/nonexistent"} - out, err := cmd.CombinedOutput() - require.NoError(t, err, "resolve script failed: output: %s", out) + out := runResolveScriptForTest(t, "HOME=/nonexistent") // No dirs should be found. assert.Empty(t, strings.TrimSpace(string(out))) } @@ -119,11 +107,7 @@ func TestResolveScriptIncludesCodexIndex(t *testing.T) { indexPath := filepath.Join(home, ".codex", "session_index.jsonl") require.NoError(t, os.WriteFile(indexPath, []byte("{}\n"), 0o644), "write index") - script := buildResolveScript() - cmd := exec.Command("sh", "-c", script) - cmd.Env = []string{"HOME=" + home} - out, err := cmd.CombinedOutput() - require.NoError(t, err, "resolve script failed: output: %s", out) + out := runResolveScriptForTest(t, "HOME="+home) // The script runs in a POSIX shell (MSYS on Windows), so it emits // forward-slash paths that differ from native filepath.Join output. @@ -155,11 +139,7 @@ func TestResolveScriptSkipsMissingCodexIndex(t *testing.T) { os.MkdirAll(filepath.Join(home, ".codex", "sessions"), 0o755), "mkdir sessions") - script := buildResolveScript() - cmd := exec.Command("sh", "-c", script) - cmd.Env = []string{"HOME=" + home} - out, err := cmd.CombinedOutput() - require.NoError(t, err, "resolve script failed: output: %s", out) + out := runResolveScriptForTest(t, "HOME="+home) _, extraFiles := parseResolvedDirs(string(out)) assert.Empty(t, extraFiles, @@ -178,11 +158,7 @@ func TestResolveScriptSkipsAiderHomeDefault(t *testing.T) { 0o644, ), "write history") - script := buildResolveScript() - cmd := exec.Command("sh", "-c", script) - cmd.Env = []string{"HOME=" + home} - out, err := cmd.CombinedOutput() - require.NoError(t, err, "resolve script failed: output: %s", out) + out := runResolveScriptForTest(t, "HOME="+home) dirs, _ := parseResolvedDirs(string(out)) assert.Empty(t, dirs[parser.AgentAider], @@ -220,11 +196,7 @@ func TestResolveScriptAiderScopedByEnvFindsHistoryFiles(t *testing.T) { deepHistory := filepath.Join(deepDir, parser.AiderHistoryFileName()) require.NoError(t, os.WriteFile(deepHistory, []byte("# aider\n"), 0o644)) - script := buildResolveScript() - cmd := exec.Command("sh", "-c", script) - cmd.Env = []string{"HOME=" + home, "AIDER_DIR=" + codeRoot} - out, err := cmd.CombinedOutput() - require.NoError(t, err, "resolve script failed: output: %s", out) + out := runResolveScriptForTest(t, "HOME="+home, "AIDER_DIR="+codeRoot) dirs, _ := parseResolvedDirs(string(out)) aiderTargets := slashPaths(dirs[parser.AgentAider]) @@ -250,11 +222,7 @@ func TestResolveScriptAiderNewlinePathCannotInjectTarget(t *testing.T) { maliciousHistory := filepath.Join(maliciousDir, parser.AiderHistoryFileName()) require.NoError(t, os.WriteFile(maliciousHistory, []byte("# aider\n"), 0o644)) - script := buildResolveScript() - cmd := exec.Command("sh", "-c", script) - cmd.Env = []string{"HOME=" + home, "AIDER_DIR=" + codeRoot} - out, err := cmd.CombinedOutput() - require.NoError(t, err, "resolve script failed: output: %s", out) + out := runResolveScriptForTest(t, "HOME="+home, "AIDER_DIR="+codeRoot) dirs, _ := parseResolvedDirs(string(out)) assert.NotContains(t, dirs[parser.AgentAider], injected, @@ -279,12 +247,8 @@ func slashPaths(paths []string) []string { func TestResolveScriptAiderRejectsHomeOverride(t *testing.T) { home := t.TempDir() - script := buildResolveScript() for _, override := range []string{home, home + "/"} { - cmd := exec.Command("sh", "-c", script) - cmd.Env = []string{"HOME=" + home, "AIDER_DIR=" + override} - out, err := cmd.CombinedOutput() - require.NoError(t, err, "resolve script failed: output: %s", out) + out := runResolveScriptForTest(t, "HOME="+home, "AIDER_DIR="+override) dirs, _ := parseResolvedDirs(string(out)) assert.Empty(t, dirs[parser.AgentAider], @@ -293,6 +257,63 @@ func TestResolveScriptAiderRejectsHomeOverride(t *testing.T) { } } +func TestResolveScriptWindsurfTargetsOnlySessionFiles(t *testing.T) { + home := t.TempDir() + userRoot := filepath.Join(home, "AppData", "Roaming", "Windsurf", "User") + workspaceRoot := filepath.Join(userRoot, "workspaceStorage") + workspaceDir := filepath.Join(workspaceRoot, "workspace-a") + stateDB := filepath.Join(workspaceDir, parser.WindsurfStateDBName) + stateWAL := stateDB + "-wal" + stateSHM := stateDB + "-shm" + workspaceJSON := filepath.Join(workspaceDir, "workspace.json") + secretPath := filepath.Join(workspaceDir, "extension-secret.json") + require.NoError(t, os.MkdirAll(workspaceDir, 0o755)) + require.NoError(t, os.WriteFile(stateDB, []byte("state"), 0o644)) + require.NoError(t, os.WriteFile(stateWAL, []byte("wal"), 0o644)) + require.NoError(t, os.WriteFile(stateSHM, []byte("shm"), 0o644)) + require.NoError(t, os.WriteFile(workspaceJSON, []byte("{}\n"), 0o644)) + require.NoError(t, os.WriteFile(secretPath, []byte("secret"), 0o644)) + + out := runResolveScriptForTest(t, "HOME="+home) + + records := resolveOutputRecords(string(out)) + userRootSuffix := filepath.ToSlash(filepath.Join("AppData", "Roaming", "Windsurf", "User")) + workspaceRootSuffix := filepath.ToSlash(filepath.Join(userRootSuffix, "workspaceStorage")) + workspaceSuffix := filepath.ToSlash(filepath.Join(workspaceRootSuffix, "workspace-a")) + agentFilePrefix := resolveAgentFilePrefix + ":" + string(parser.AgentWindsurf) + assert.True(t, hasRecordWithPathSuffix(records, string(parser.AgentWindsurf), userRootSuffix)) + assert.True(t, hasRecordWithPathSuffix(records, agentFilePrefix, + filepath.ToSlash(filepath.Join(workspaceSuffix, parser.WindsurfStateDBName)))) + assert.True(t, hasRecordWithPathSuffix(records, agentFilePrefix, + filepath.ToSlash(filepath.Join(workspaceSuffix, parser.WindsurfStateDBName+"-wal")))) + assert.False(t, hasRecordWithPathSuffix(records, agentFilePrefix, + filepath.ToSlash(filepath.Join(workspaceSuffix, parser.WindsurfStateDBName+"-shm")))) + assert.True(t, hasRecordWithPathSuffix(records, agentFilePrefix, + filepath.ToSlash(filepath.Join(workspaceSuffix, "workspace.json")))) + assert.False(t, hasRecordWithPathSuffix(records, string(parser.AgentWindsurf), workspaceRootSuffix)) + assert.False(t, hasRecordWithPathSuffix(records, agentFilePrefix, + filepath.ToSlash(filepath.Join(workspaceSuffix, filepath.Base(secretPath))))) +} + +func hasRecordWithPathSuffix(records []string, prefix, suffix string) bool { + for _, record := range records { + if strings.HasPrefix(record, prefix+":") && strings.HasSuffix(record, suffix) { + return true + } + } + return false +} + +func runResolveScriptForTest(t *testing.T, env ...string) []byte { + t.Helper() + cmd := exec.Command("sh") + cmd.Stdin = strings.NewReader(buildResolveScript()) + cmd.Env = env + out, err := cmd.CombinedOutput() + require.NoError(t, err, "resolve script failed: output: %s", out) + return out +} + func TestParseResolvedDirs(t *testing.T) { input := "claude:/home/wes/.claude/projects\n" + "codex:/home/wes/.codex/sessions\n" + @@ -332,3 +353,21 @@ func TestParseResolvedDirsNULRecords(t *testing.T) { assert.Equal(t, []string{"/home/wes/.codex/session_index.jsonl"}, extraFiles) } + +func TestParseResolvedTargetsIncludesAgentFiles(t *testing.T) { + input := "windsurf:/home/wes/Windsurf/User\x00" + + "@agentfile:windsurf:/home/wes/Windsurf/User/workspaceStorage/a/state.vscdb\x00" + + "@agentfile:windsurf:/home/wes/Windsurf/User/workspaceStorage/a/state.vscdb\x00" + + "@agentfile:windsurf:/home/wes/Windsurf/User/workspaceStorage/a/workspace.json\x00" + + "@file:/home/wes/.codex/session_index.jsonl\x00" + + dirs, files, extraFiles := parseResolvedTargets(input) + + assert.Equal(t, []string{"/home/wes/Windsurf/User"}, dirs[parser.AgentWindsurf]) + assert.Equal(t, []string{ + "/home/wes/Windsurf/User/workspaceStorage/a/state.vscdb", + "/home/wes/Windsurf/User/workspaceStorage/a/workspace.json", + }, files[parser.AgentWindsurf]) + assert.Equal(t, + []string{"/home/wes/.codex/session_index.jsonl"}, extraFiles) +} diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go index f05fb07ab..c0ee085ce 100644 --- a/internal/ssh/ssh.go +++ b/internal/ssh/ssh.go @@ -17,9 +17,8 @@ const sshConnectTimeoutSecs = 10 // buildSSHArgs constructs args for the ssh command. // -// Remote commands are always executed through a POSIX shell via -// "sh -c ''" so behavior is independent of the remote user's -// login shell (e.g. fish). +// Remote commands run through a POSIX shell so behavior is independent +// of the remote user's login shell (e.g. fish). // // The invocation is non-interactive: it passes BatchMode=yes (never // prompt for a password/passphrase -- remote sync requires key-based @@ -29,11 +28,27 @@ const sshConnectTimeoutSecs = 10 // value seen for each option). // // Returns ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=N", -// "--", "user@host", "sh -c ''"] (or "host" when user is +// "--", "user@host", ] (or "host" when user is // empty). Port adds "-p N" when > 0; extra sshOpts (e.g. "-i // keyfile") are inserted before the defaults. func buildSSHArgs( host, user string, port int, sshOpts []string, cmd string, +) ([]string, error) { + return buildSSHArgsForRemoteCommand( + host, user, port, sshOpts, "sh -c "+shellQuote(cmd), + ) +} + +func buildSSHScriptArgs( + host, user string, port int, sshOpts []string, +) ([]string, error) { + return buildSSHArgsForRemoteCommand( + host, user, port, sshOpts, "sh -s", + ) +} + +func buildSSHArgsForRemoteCommand( + host, user string, port int, sshOpts []string, remoteCmd string, ) ([]string, error) { if isOptionShapedTargetPart(host) { return nil, fmt.Errorf("ssh target host must not begin with '-'") @@ -45,7 +60,6 @@ func buildSSHArgs( if user != "" { target = user + "@" + host } - remoteCmd := "sh -c " + shellQuote(cmd) args := []string{"ssh"} if port > 0 { args = append(args, "-p", strconv.Itoa(port)) @@ -62,18 +76,17 @@ func isOptionShapedTargetPart(value string) bool { return strings.HasPrefix(strings.TrimSpace(value), "-") } -// runSSH executes a command on the remote host and returns stdout. -// Returns an error containing stderr content on failure. -func runSSH( +func runSSHScript( ctx context.Context, host, user string, port int, sshOpts []string, - cmd string, + script string, ) ([]byte, error) { - args, err := buildSSHArgs(host, user, port, sshOpts, cmd) + args, err := buildSSHScriptArgs(host, user, port, sshOpts) if err != nil { return nil, err } c := exec.CommandContext(ctx, args[0], args[1:]...) + c.Stdin = strings.NewReader(script) var stderr bytes.Buffer c.Stderr = &stderr out, err := c.Output() @@ -89,20 +102,17 @@ func runSSH( return out, nil } -// runSSHStream executes a command on the remote host and returns a -// reader for stdout. Caller must call the returned cleanup func when -// done to wait for the process and release resources. Used for tar -// streams where buffering full output is impractical. -func runSSHStream( +func runSSHScriptStream( ctx context.Context, host, user string, port int, sshOpts []string, - cmd string, + script string, ) (io.ReadCloser, func() error, error) { - args, err := buildSSHArgs(host, user, port, sshOpts, cmd) + args, err := buildSSHScriptArgs(host, user, port, sshOpts) if err != nil { return nil, nil, err } c := exec.CommandContext(ctx, args[0], args[1:]...) + c.Stdin = strings.NewReader(script) var stderr bytes.Buffer c.Stderr = &stderr diff --git a/internal/ssh/ssh_test.go b/internal/ssh/ssh_test.go index f2bc69878..85d72f410 100644 --- a/internal/ssh/ssh_test.go +++ b/internal/ssh/ssh_test.go @@ -117,6 +117,16 @@ func TestBuildSSHArgs_NonInteractiveDefaults(t *testing.T) { "ssh invocation must include non-interactive defaults") } +func TestBuildSSHScriptArgs(t *testing.T) { + got, err := buildSSHScriptArgs("devbox1", "wes", 2222, []string{"-i", "/tmp/key"}) + assert.NoError(t, err) + assert.Equal(t, wantSSHArgs( + []string{"-p", "2222", "-i", "/tmp/key"}, + "wes@devbox1", + "sh -s", + ), got) +} + func TestBuildSSHArgsRejectsOptionShapedTargetParts(t *testing.T) { tests := []struct { name string diff --git a/internal/ssh/sync.go b/internal/ssh/sync.go index d9da51cc9..3cca06c72 100644 --- a/internal/ssh/sync.go +++ b/internal/ssh/sync.go @@ -39,7 +39,7 @@ func (rs *RemoteSync) Run( fmt.Printf( "Resolving agent directories on %s...\n", rs.Host, ) - dirs, extraFiles, err := resolveDirs( + dirs, files, extraFiles, err := resolveDirs( ctx, rs.Host, rs.User, rs.Port, rs.SSHOpts, ) if err != nil { @@ -62,7 +62,7 @@ func (rs *RemoteSync) Run( rs.Host, len(dirs), ) tmpDir, err := downloadAndExtract( - ctx, rs.Host, rs.User, rs.Port, rs.SSHOpts, dirs, extraFiles, + ctx, rs.Host, rs.User, rs.Port, rs.SSHOpts, dirs, files, extraFiles, ) if err != nil { return stats, fmt.Errorf( @@ -104,6 +104,7 @@ func (rs *RemoteSync) Run( Progress: progress, }.ImportExtracted(ctx, remotesync.TargetSet{ Dirs: dirs, + Files: files, ExtraFiles: extraFiles, }, tmpDir) if lastProgress.SessionsTotal > 0 { diff --git a/internal/ssh/transfer.go b/internal/ssh/transfer.go index f701cafd0..ebd716257 100644 --- a/internal/ssh/transfer.go +++ b/internal/ssh/transfer.go @@ -14,25 +14,66 @@ import ( "go.kenn.io/agentsview/internal/remotesync" ) -// buildTarCommand generates the remote tar command for the given -// agent directories and extra files. Uses -C / so paths are relative -// to root. Strips leading / from each path and shell-quotes it. The -// extra files are resolved to exist on the remote (see -// buildResolveScript), so tar does not fail on a missing path. +// buildTarCommand generates the remote shell script for the given +// agent directories, agent-scoped files, and extra files. Uses -C / +// so paths are relative to root, and feeds paths to tar over stdin +// instead of expanding them as tar argv. The script itself is sent to +// the remote shell over stdin, so a large file-scoped Windsurf export +// does not consume ssh/exec argument space. func buildTarCommand( dirs map[parser.AgentType][]string, + files map[parser.AgentType][]string, extraFiles []string, ) string { var paths []string - for _, agentDirs := range dirs { + for agent, agentDirs := range dirs { + if _, fileScoped := files[agent]; fileScoped { + continue + } for _, d := range agentDirs { - paths = append(paths, shellQuote(strings.TrimPrefix(d, "/"))) + if path := tarListPath(d); path != "" { + paths = append(paths, shellQuote(path)) + } + } + } + for _, agentFiles := range files { + for _, f := range agentFiles { + if path := tarListPath(f); path != "" { + paths = append(paths, shellQuote(path)) + } } } for _, f := range extraFiles { - paths = append(paths, shellQuote(strings.TrimPrefix(f, "/"))) + if path := tarListPath(f); path != "" { + paths = append(paths, shellQuote(path)) + } + } + var b strings.Builder + b.WriteString("set -e\n") + b.WriteString("av_emit_tar_path() { [ -e \"/$1\" ] || return 0; printf '%s\\n' \"$1\"; }\n") + b.WriteString("{\n") + b.WriteString(":\n") + for _, path := range paths { + b.WriteString("av_emit_tar_path ") + b.WriteString(path) + b.WriteByte('\n') + } + b.WriteString("} | tar cf - -C / -T -\n") + return b.String() +} + +func tarListPath(path string) string { + if strings.ContainsAny(path, "\x00\n\r") { + return "" + } + rel := strings.TrimPrefix(path, "/") + if rel == "" || rel == "." { + return "" + } + if strings.HasPrefix(rel, "./") { + return rel } - return "tar cf - -C / -- " + strings.Join(paths, " ") + return "./" + rel } // shellQuote wraps s in single quotes, escaping any embedded @@ -47,10 +88,11 @@ func downloadAndExtract( ctx context.Context, host, user string, port int, sshOpts []string, dirs map[parser.AgentType][]string, + files map[parser.AgentType][]string, extraFiles []string, ) (string, error) { - tarCmd := buildTarCommand(dirs, extraFiles) - stdout, cleanup, err := runSSHStream( + tarCmd := buildTarCommand(dirs, files, extraFiles) + stdout, cleanup, err := runSSHScriptStream( ctx, host, user, port, sshOpts, tarCmd, ) if err != nil { diff --git a/internal/ssh/transfer_test.go b/internal/ssh/transfer_test.go index 83666f32e..123d1e0b6 100644 --- a/internal/ssh/transfer_test.go +++ b/internal/ssh/transfer_test.go @@ -1,11 +1,18 @@ package ssh import ( + "archive/tar" + "bytes" + "io" + "os" + "os/exec" "path/filepath" + "runtime" "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.kenn.io/agentsview/internal/parser" ) @@ -14,18 +21,169 @@ func TestBuildTarCommand(t *testing.T) { parser.AgentClaude: {"/home/wes/.claude/projects"}, parser.AgentCodex: {"/home/wes/.codex/sessions"}, } - cmd := buildTarCommand(dirs, []string{"/home/wes/.codex/session_index.jsonl"}) + cmd := buildTarCommand(dirs, nil, []string{"/home/wes/.codex/session_index.jsonl"}) - assert.True(t, strings.HasPrefix(cmd, "tar cf - -C / -- "), "bad prefix: %s", cmd) - // Paths are shell-quoted. - assert.Contains(t, cmd, "'home/wes/.claude/projects'") - assert.Contains(t, cmd, "'home/wes/.codex/sessions'") - // Extra files are included, shell-quoted, with no leading slash. - assert.Contains(t, cmd, "'home/wes/.codex/session_index.jsonl'") + assert.Contains(t, cmd, "| tar cf - -C / -T -", "bad tar pipe: %s", cmd) + assert.NotContains(t, tarCommandLine(t, cmd), "home/wes/.claude/projects", + "tar invocation must read paths from stdin, not argv") + // Paths are shell-quoted in the streamed path list and prefixed with + // ./ so tar cannot treat option-shaped file-list entries as options. + assert.Contains(t, cmd, "'./home/wes/.claude/projects'") + assert.Contains(t, cmd, "'./home/wes/.codex/sessions'") + // Extra files are included in the path list, with no leading slash. + assert.Contains(t, cmd, "'./home/wes/.codex/session_index.jsonl'") // No leading slash in path args. assert.NotContains(t, cmd, "'/home/", "path has leading slash: %s", cmd) } +func TestBuildTarCommandSkipsFileScopedWindsurfDirs(t *testing.T) { + dirs := map[parser.AgentType][]string{ + parser.AgentWindsurf: {"/home/wes/Windsurf/User"}, + } + files := map[parser.AgentType][]string{ + parser.AgentWindsurf: { + "/home/wes/Windsurf/User/workspaceStorage/a/state.vscdb", + "/home/wes/Windsurf/User/workspaceStorage/a/workspace.json", + }, + } + + cmd := buildTarCommand(dirs, files, nil) + + assert.Contains(t, cmd, "'./home/wes/Windsurf/User/workspaceStorage/a/state.vscdb'") + assert.Contains(t, cmd, "'./home/wes/Windsurf/User/workspaceStorage/a/workspace.json'") + assert.NotContains(t, cmd, "'./home/wes/Windsurf/User'", + "file-scoped Windsurf root must not be archived recursively: %s", cmd) +} + +func TestTarListPathProtectsOptionShapedPath(t *testing.T) { + assert.Equal(t, "./-dash/session.jsonl", tarListPath("/-dash/session.jsonl")) + assert.Equal(t, "./home/wes/file.jsonl", tarListPath("/home/wes/file.jsonl")) + assert.Equal(t, "./already/relative.jsonl", tarListPath("./already/relative.jsonl")) + assert.Empty(t, tarListPath("/")) + assert.Empty(t, tarListPath("/home/wes/bad\npath.jsonl")) + assert.Empty(t, tarListPath("/home/wes/bad\rpath.jsonl")) + assert.Empty(t, tarListPath("/home/wes/bad\x00path.jsonl")) +} + +func TestBuildTarCommandStreamsPathListToTar(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("remote tar script uses POSIX paths; local Windows paths are not representative") + } + + root := t.TempDir() + claudeDir := filepath.Join(root, "home", "wes", ".claude", "projects") + claudeFile := filepath.Join(claudeDir, "session.jsonl") + windsurfDir := filepath.Join(root, "home", "wes", "Windsurf", "User", "workspaceStorage", "a") + stateDB := filepath.Join(windsurfDir, parser.WindsurfStateDBName) + workspaceJSON := filepath.Join(windsurfDir, "workspace.json") + require.NoError(t, os.MkdirAll(claudeDir, 0o755)) + require.NoError(t, os.MkdirAll(windsurfDir, 0o755)) + require.NoError(t, os.WriteFile(claudeFile, []byte("{}\n"), 0o644)) + require.NoError(t, os.WriteFile(stateDB, []byte("state"), 0o644)) + require.NoError(t, os.WriteFile(workspaceJSON, []byte("{}\n"), 0o644)) + + script := buildTarCommand( + map[parser.AgentType][]string{ + parser.AgentClaude: {claudeDir}, + parser.AgentWindsurf: {filepath.Dir(filepath.Dir(windsurfDir))}, + }, + map[parser.AgentType][]string{ + parser.AgentWindsurf: {stateDB, workspaceJSON}, + }, + nil, + ) + cmd := exec.Command("sh") + cmd.Stdin = strings.NewReader(script) + archive, err := cmd.Output() + require.NoError(t, err) + names := tarNames(t, archive) + assert.Contains(t, names, archivePathForTest(claudeFile)) + assert.Contains(t, names, archivePathForTest(stateDB)) + assert.Contains(t, names, archivePathForTest(workspaceJSON)) +} + +func TestBuildTarCommandSkipsMissingFileScopedPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("remote tar script uses POSIX paths; local Windows paths are not representative") + } + + root := t.TempDir() + windsurfDir := filepath.Join(root, "home", "wes", "Windsurf", "User", "workspaceStorage", "a") + stateDB := filepath.Join(windsurfDir, parser.WindsurfStateDBName) + missingWAL := stateDB + "-wal" + require.NoError(t, os.MkdirAll(windsurfDir, 0o755)) + require.NoError(t, os.WriteFile(stateDB, []byte("state"), 0o644)) + + script := buildTarCommand( + map[parser.AgentType][]string{ + parser.AgentWindsurf: {filepath.Dir(filepath.Dir(windsurfDir))}, + }, + map[parser.AgentType][]string{ + parser.AgentWindsurf: {stateDB, missingWAL}, + }, + nil, + ) + cmd := exec.Command("sh") + cmd.Stdin = strings.NewReader(script) + archive, err := cmd.Output() + require.NoError(t, err) + names := tarNames(t, archive) + assert.Contains(t, names, archivePathForTest(stateDB)) + assert.NotContains(t, names, archivePathForTest(missingWAL)) +} + +func TestBuildTarCommandSkipsLineDelimitedUnsafePath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("remote tar script uses POSIX paths; local Windows paths are not representative") + } + + root := t.TempDir() + dir := filepath.Join(root, "sessions") + safeFile := filepath.Join(dir, "safe.jsonl") + unsafeFile := filepath.Join(dir, "bad\nname.jsonl") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(safeFile, []byte("{}\n"), 0o644)) + require.NoError(t, os.WriteFile(unsafeFile, []byte("{}\n"), 0o644)) + + script := buildTarCommand(nil, nil, []string{safeFile, unsafeFile}) + cmd := exec.Command("sh") + cmd.Stdin = strings.NewReader(script) + archive, err := cmd.Output() + require.NoError(t, err) + names := tarNames(t, archive) + assert.Contains(t, names, archivePathForTest(safeFile)) + assert.NotContains(t, names, archivePathForTest(unsafeFile)) +} + +func tarCommandLine(t *testing.T, script string) string { + t.Helper() + for line := range strings.SplitSeq(script, "\n") { + if strings.Contains(line, "tar cf") { + return line + } + } + require.FailNow(t, "tar command line not found", "script: %s", script) + return "" +} + +func tarNames(t *testing.T, archive []byte) []string { + t.Helper() + tr := tar.NewReader(bytes.NewReader(archive)) + var names []string + for { + hdr, err := tr.Next() + if err == io.EOF { + return names + } + require.NoError(t, err) + names = append(names, hdr.Name) + } +} + +func archivePathForTest(path string) string { + return "./" + strings.TrimPrefix(filepath.ToSlash(path), "/") +} + func TestRemapPath(t *testing.T) { // Use filepath.Join so the local paths are OS-native. // remapToRemotePath always returns forward-slash paths. diff --git a/internal/sync/engine.go b/internal/sync/engine.go index c6d53c2c8..bf34d13b4 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -4197,7 +4197,7 @@ func providerProcessCacheKeyWithHash( func providerFingerprintHashRequiredForFreshness(agent parser.AgentType) bool { switch agent { - case parser.AgentDevin, parser.AgentQoder: + case parser.AgentDevin, parser.AgentQoder, parser.AgentWindsurf: return true default: return false @@ -7840,7 +7840,13 @@ func (e *Engine) providerSessionSourceMtime( } func providerSourcePathNeedsFingerprint(path string) bool { - return path != "" && parser.ResolveSourceFilePath(path) != path + if path == "" { + return false + } + if _, _, ok := parser.SplitWindsurfVirtualPath(path); ok { + return true + } + return parser.ResolveSourceFilePath(path) != path } func providerSourceMtimeNeedsFingerprint(agent parser.AgentType) bool { diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index 8618dc65d..f7655363e 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -41,6 +41,7 @@ type testEnv struct { ompDir string kiroDir string shelleyDir string + windsurfDir string antigravityCLIDir string db *db.DB engine *sync.Engine @@ -277,6 +278,8 @@ func assignFocusedAgentDir( env.kiroDir = dir case parser.AgentShelley: env.shelleyDir = dir + case parser.AgentWindsurf: + env.windsurfDir = dir case parser.AgentAntigravityCLI: env.antigravityCLIDir = dir default: diff --git a/internal/sync/parsediff.go b/internal/sync/parsediff.go index 8d4ec7148..e04cc5b79 100644 --- a/internal/sync/parsediff.go +++ b/internal/sync/parsediff.go @@ -409,6 +409,7 @@ func parseDiffSourceKey(path string) string { var perSessionDBVirtualSourceBases = []string{ "opencode.db", "kilo.db", "mimocode.db", "sessions.db", parser.WarpDBFilename, parser.ForgeDBFilename, parser.PiebaldDBFilename, + parser.WindsurfStateDBName, } func isPerSessionDBVirtualSource(path string) bool { diff --git a/internal/sync/parsediff_dbbacked_test.go b/internal/sync/parsediff_dbbacked_test.go index ae9603f9a..63126ddd4 100644 --- a/internal/sync/parsediff_dbbacked_test.go +++ b/internal/sync/parsediff_dbbacked_test.go @@ -11,6 +11,7 @@ package sync_test import ( "database/sql" "fmt" + "os" "path/filepath" "testing" @@ -90,6 +91,32 @@ func (w *warpTestDB) addConversation( } } +func createWindsurfWorkspaceDB(t *testing.T, root, payload string) string { + t.Helper() + workspaceDir := filepath.Join(root, "workspaceStorage", "workspace-hash") + require.NoError(t, os.MkdirAll(workspaceDir, 0o755)) + require.NoError(t, + os.WriteFile( + filepath.Join(workspaceDir, "workspace.json"), + []byte(`{"folder":"file:///work/demo"}`), + 0o644, + ), + ) + dbPath := filepath.Join(workspaceDir, "state.vscdb") + conn, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + _, err = conn.Exec(`CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)`) + require.NoError(t, err) + _, err = conn.Exec( + `INSERT INTO ItemTable (key, value) VALUES (?, ?)`, + "workbench.panel.aichat.view.aichat.chatdata", + payload, + ) + require.NoError(t, err) + return dbPath +} + // TestParseDiffCoversForge proves Forge's shared .forge.db, discovered as one // virtual source per conversation, is re-parsed and compared by parse-diff. // Examined:1/Identical:1 means the stored conversation was matched and vetted, @@ -297,3 +324,52 @@ func TestParseDiffDBBackedLimitOrdersByPerSessionMtime(t *testing.T) { assert.Contains(t, skipped[0].Reason, "limit", "cut session reads as not-sampled") } + +func TestParseDiffWindsurfLimitScopesPerSession(t *testing.T) { + env := setupSingleAgentTestEnv(t, parser.AgentWindsurf) + createWindsurfWorkspaceDB(t, env.windsurfDir, `{ + "tabs": [ + { + "tabId": "windsurf-a", + "chatTitle": "Conversation A", + "bubbles": [ + {"type": "user", "text": "Prompt A."}, + {"type": "assistant", "text": "Answer A."} + ] + }, + { + "tabId": "windsurf-b", + "chatTitle": "Conversation B", + "bubbles": [ + {"type": "user", "text": "Prompt B."}, + {"type": "assistant", "text": "Answer B."} + ] + } + ] + }`) + runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2}) + + report := runParseDiff(t, env, sync.ParseDiffOptions{ + Agents: []parser.AgentType{parser.AgentWindsurf}, + Limit: 1, + }) + + assert.True(t, report.FilesLimited, "files limited") + assert.Equal(t, sync.ParseDiffTotals{ + Examined: 1, Identical: 1, Skipped: 1, + }, report.Totals, "one Windsurf tab sampled, one cut") + assert.Zero(t, report.Totals.Changed, + "cut Windsurf sibling must not become a presence change") + assert.Empty(t, report.FieldCounts, + "no field drift from an unsampled Windsurf sibling") + + var skipped []sync.SessionDiff + for _, s := range report.Sessions { + if s.Class == sync.DiffSkipped { + skipped = append(skipped, s) + } + } + require.Len(t, skipped, 1, "exactly one skipped Windsurf session listed") + assert.Contains(t, skipped[0].Reason, "limit", + "cut Windsurf session reads as not-sampled") +} diff --git a/internal/sync/parsediff_integration_test.go b/internal/sync/parsediff_integration_test.go index 0f3c139ce..e165a3e4f 100644 --- a/internal/sync/parsediff_integration_test.go +++ b/internal/sync/parsediff_integration_test.go @@ -57,6 +57,7 @@ func parseDiffAgentDirs(env *testEnv) map[parser.AgentType][]string { add(parser.AgentKiro, env.kiroDir) add(parser.AgentKilo, env.kiloDir) add(parser.AgentShelley, env.shelleyDir) + add(parser.AgentWindsurf, env.windsurfDir) add(parser.AgentAntigravityCLI, env.antigravityCLIDir) return dirs } diff --git a/internal/sync/windsurf_integration_test.go b/internal/sync/windsurf_integration_test.go new file mode 100644 index 000000000..8cbf2d223 --- /dev/null +++ b/internal/sync/windsurf_integration_test.go @@ -0,0 +1,193 @@ +package sync + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/parser" +) + +func TestSourceMtimeWindsurfUsesProviderFingerprint(t *testing.T) { + root := filepath.Join(t.TempDir(), "Windsurf", "User") + workspaceDir := filepath.Join(root, "workspaceStorage", "workspace-hash") + manifestPath := filepath.Join(workspaceDir, "workspace.json") + dbPath := filepath.Join(workspaceDir, "state.vscdb") + require.NoError(t, os.MkdirAll(workspaceDir, 0o755)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`{"folder":"file:///work/demo"}`), 0o644)) + writeSyncWindsurfStateDB(t, dbPath, `{ + "version": 1, + "sessionId": "mtime-session", + "requests": [{ + "requestId": "request-1", + "message": {"text": "Question"}, + "response": [{"value": "Answer"}], + "timestamp": 1710000000000 + }] + }`) + database := dbtest.OpenTestDB(t) + engine := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentWindsurf: {root}, + }, + Machine: "devbox", + }) + defer engine.Close() + + stats := engine.SyncAll(context.Background(), nil) + require.Equal(t, 1, stats.Synced) + virtualPath := dbPath + "#mtime-session" + assert.Equal(t, virtualPath, engine.FindSourceFile("windsurf:mtime-session")) + before := engine.SourceMtime("windsurf:mtime-session") + require.NotZero(t, before) + + future := time.Unix(0, before).Add(2 * time.Second) + require.NoError(t, os.Chtimes(manifestPath, future, future)) + + after := engine.SourceMtime("windsurf:mtime-session") + assert.Greater(t, after, before) +} + +func TestProcessFileWindsurfSameMtimeHashChangeReparses(t *testing.T) { + for _, tt := range []struct { + name string + seedCache bool + freshSync bool + }{ + {name: "skip cache", seedCache: true}, + {name: "db freshness", freshSync: true}, + } { + t.Run(tt.name, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "Windsurf", "User") + workspaceDir := filepath.Join(root, "workspaceStorage", "workspace-hash") + manifestPath := filepath.Join(workspaceDir, "workspace.json") + dbPath := filepath.Join(workspaceDir, "state.vscdb") + require.NoError(t, os.MkdirAll(workspaceDir, 0o755)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`{"folder":"file:///work/demo"}`), 0o644)) + writeSyncWindsurfStateDB(t, dbPath, windsurfSyncPayload("hash-session", "Alpha reply")) + virtualPath := dbPath + "#hash-session" + database := dbtest.OpenTestDB(t) + engine := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentWindsurf: {root}, + }, + Machine: "devbox", + }) + defer engine.Close() + + initialMtime, initialHash := syncInitialWindsurfSession( + t, engine, "hash-session", + ) + + infoBefore, err := os.Stat(dbPath) + require.NoError(t, err) + updateSyncWindsurfStateDB(t, dbPath, windsurfSyncPayload("hash-session", "Bravo reply")) + initialTime := time.Unix(0, initialMtime) + require.NoError(t, os.Chtimes(dbPath, initialTime, initialTime)) + infoAfter, err := os.Stat(dbPath) + require.NoError(t, err) + require.Equal(t, infoBefore.Size(), infoAfter.Size(), + "test must keep size stable so hash is the only freshness signal") + + if tt.seedCache { + engine.cacheSkip( + providerProcessCacheKeyWithHash( + virtualPath, + parser.AgentWindsurf, + parser.SourceFingerprint{Hash: initialHash}, + ), + initialMtime, + ) + } + if tt.freshSync { + engine.Close() + engine = NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentWindsurf: {root}, + }, + Machine: "devbox", + }) + defer engine.Close() + } + + second := engine.processFile(context.Background(), parser.DiscoveredFile{ + Path: virtualPath, + Agent: parser.AgentWindsurf, + }) + require.NoError(t, second.err) + assert.False(t, second.skip) + require.Len(t, second.results, 1) + require.Len(t, second.results[0].Messages, 2) + assert.Equal(t, "Bravo reply", second.results[0].Messages[1].Content) + assert.NotEqual(t, initialHash, second.results[0].Session.File.Hash) + }) + } +} + +func writeSyncWindsurfStateDB(t *testing.T, dbPath, payload string) { + t.Helper() + conn, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Exec(`CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)`) + require.NoError(t, err) + _, err = conn.Exec( + `INSERT INTO ItemTable (key, value) VALUES (?, ?)`, + "workbench.panel.aichat.view.aichat.chatdata", + payload, + ) + require.NoError(t, err) +} + +func updateSyncWindsurfStateDB(t *testing.T, dbPath, payload string) { + t.Helper() + conn, err := sql.Open("sqlite3", dbPath) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Exec( + `UPDATE ItemTable SET value = ? WHERE key = ?`, + payload, + "workbench.panel.aichat.view.aichat.chatdata", + ) + require.NoError(t, err) +} + +func syncInitialWindsurfSession( + t *testing.T, + engine *Engine, + sessionID string, +) (int64, string) { + t.Helper() + stats := engine.SyncAll(context.Background(), nil) + require.Equal(t, 1, stats.Synced) + sess, err := engine.db.GetSessionFull( + context.Background(), "windsurf:"+sessionID, + ) + require.NoError(t, err) + require.NotNil(t, sess) + require.NotNil(t, sess.FileMtime) + require.NotNil(t, sess.FileHash) + require.NotZero(t, *sess.FileMtime) + require.NotEmpty(t, *sess.FileHash) + return *sess.FileMtime, *sess.FileHash +} + +func windsurfSyncPayload(sessionID, assistant string) string { + return `{ + "version": 1, + "sessionId": "` + sessionID + `", + "requests": [{ + "requestId": "request-1", + "message": {"text": "Question"}, + "response": [{"value": "` + assistant + `"}], + "timestamp": 1710000000000 + }] + }` +}