From 439a9a482fe94beaebc4e09d1db14fc63b33ff25 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 7 Jul 2026 14:01:13 -0400 Subject: [PATCH 01/12] feat(parser): add Windsurf workspace chat support (#696) - Prevent resolver script truncation on Windows - Handle Windsurf deletion and numeric bubbles - Drop obsolete SSH command helper - Retry corrupted benchmark capture - Keep benchmark output parseable during setup - fix(parser): scope windsurf virtual sources (#696) --- cmd/agentsview/session_export.go | 11 + internal/db/usage_test.go | 6 + internal/parser/provider.go | 2 + internal/parser/provider_migration.go | 1 + internal/parser/types.go | 27 + internal/parser/types_test.go | 30 + internal/parser/visualstudio_copilot.go | 9 +- internal/parser/visualstudio_copilot_test.go | 8 + internal/parser/windsurf_provider.go | 856 +++++++++++++++++++ internal/parser/windsurf_provider_test.go | 379 ++++++++ internal/remotesync/resolve.go | 18 + internal/remotesync/resolve_test.go | 19 +- internal/service/direct.go | 32 +- internal/service/direct_test.go | 52 ++ internal/ssh/resolve.go | 17 +- internal/ssh/resolve_test.go | 86 +- internal/ssh/ssh.go | 33 +- internal/ssh/ssh_test.go | 10 + internal/sync/engine.go | 8 +- internal/sync/engine_integration_test.go | 3 + internal/sync/parsediff.go | 1 + internal/sync/parsediff_dbbacked_test.go | 76 ++ internal/sync/parsediff_integration_test.go | 1 + internal/sync/windsurf_integration_test.go | 71 ++ 24 files changed, 1684 insertions(+), 72 deletions(-) create mode 100644 internal/parser/windsurf_provider.go create mode 100644 internal/parser/windsurf_provider_test.go create mode 100644 internal/sync/windsurf_integration_test.go 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/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/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..7b6d4389b --- /dev/null +++ b/internal/parser/windsurf_provider.go @@ -0,0 +1,856 @@ +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 + "-*", "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", + src.DBPath+"-shm", + 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", windsurfStateDBName + "-shm", "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 +} + +func listWindsurfSessionRecords(dbPath string) ([]windsurfSessionRecord, 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() + + seen := make(map[string]struct{}) + var records []windsurfSessionRecord + 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) + } + next, err := windsurfRecordsFromValue( + []byte(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 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", + dbPath+"-shm", + 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 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() + for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm", workspacePath} { + if path == "" || !IsRegularFile(path) { + continue + } + hash, err := hashJSONLSourceFile(path) + if err != nil { + return "", err + } + _, _ = h.Write([]byte(path)) + _, _ = 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..f1efc5ce9 --- /dev/null +++ b/internal/parser/windsurf_provider_test.go @@ -0,0 +1,379 @@ +package parser + +import ( + "bytes" + "context" + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + + _ "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 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-*") + 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 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 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/resolve.go b/internal/remotesync/resolve.go index 06dca9fb0..03d00fe9f 100644 --- a/internal/remotesync/resolve.go +++ b/internal/remotesync/resolve.go @@ -25,6 +25,13 @@ func ResolveTargets(cfg config.Config) TargetSet { } continue } + if def.Type == parser.AgentWindsurf { + target := resolveWindsurfTarget(dir) + if target != "" { + dirs[def.Type] = append(dirs[def.Type], target) + } + continue + } if info, err := os.Stat(dir); err != nil || !info.IsDir() { continue } @@ -79,6 +86,17 @@ func resolveAiderTargets(root string) []string { return out } +func resolveWindsurfTarget(root string) string { + target := filepath.Clean(root) + if filepath.Base(target) != "workspaceStorage" { + target = filepath.Join(target, "workspaceStorage") + } + if info, err := os.Stat(target); err == nil && info.IsDir() { + return target + } + return "" +} + func providerDiscoveredPath(source parser.SourceRef) string { for _, path := range []string{ source.DisplayPath, diff --git a/internal/remotesync/resolve_test.go b/internal/remotesync/resolve_test.go index 8053dd1ae..ce0a8c82f 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,11 +25,14 @@ 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") 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(windsurfWorkspaceRoot, 0o755)) require.NoError(t, os.WriteFile(aiderHistory, []byte("# aider\n"), 0o644)) codexIndex := filepath.Join(root, ".codex", parser.CodexSessionIndexFilename) require.NoError(t, os.WriteFile(codexIndex, []byte("{}\n"), 0o644)) @@ -41,6 +45,9 @@ func TestResolveTargetsFiltersAndIncludesSpecialFiles(t *testing.T) { parser.AgentWarp: {warpDir}, parser.AgentAider: {aiderRoot}, parser.AgentZed: {filepath.Join(root, "zed")}, + parser.AgentWindsurf: { + windsurfUserRoot, + }, }, }) @@ -50,6 +57,8 @@ 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{windsurfWorkspaceRoot}, targets.Dirs[parser.AgentWindsurf]) + assert.NotContains(t, targets.Dirs[parser.AgentWindsurf], windsurfUserRoot) assert.Contains(t, targets.ExtraFiles, codexIndex) } @@ -121,15 +130,19 @@ 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") 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(windsurfWorkspaceRoot, 0o755)) require.NoError(t, os.WriteFile(aiderHistory, []byte("# aider\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) @@ -141,6 +154,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 +164,6 @@ 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, sshDirs[parser.AgentWindsurf], goTargets.Dirs[parser.AgentWindsurf]) assert.ElementsMatch(t, sshExtra, goTargets.ExtraFiles) } 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..20b4b68e1 100644 --- a/internal/ssh/resolve.go +++ b/internal/ssh/resolve.go @@ -74,17 +74,26 @@ func buildAiderResolveSnippet(envVar string) string { func buildResolveScript() string { var b strings.Builder b.WriteString( - "av_emit_dir() { " + + "av_emit_target() { " + + "agent=\"$1\"; " + + "target=\"$2\"; " + + "if [ \"$agent\" = \"" + string(parser.AgentWindsurf) + "\" ]; then " + + "case \"$target\" in */workspaceStorage) ;; " + + "*) target=\"$target/workspaceStorage\";; esac; " + + "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 + "\"; " + @@ -250,7 +259,7 @@ func resolveDirs( host, user string, port int, sshOpts []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) } diff --git a/internal/ssh/resolve_test.go b/internal/ssh/resolve_test.go index c2d7c8ca3..dbccaabbf 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,32 @@ func TestResolveScriptAiderRejectsHomeOverride(t *testing.T) { } } +func TestResolveScriptWindsurfTargetsWorkspaceStorage(t *testing.T) { + home := t.TempDir() + userRoot := filepath.Join(home, "AppData", "Roaming", "Windsurf", "User") + workspaceRoot := filepath.Join(userRoot, "workspaceStorage") + require.NoError(t, os.MkdirAll(workspaceRoot, 0o755)) + + out := runResolveScriptForTest(t, "HOME="+home) + + dirs, _ := parseResolvedDirs(string(out)) + targets := slashPaths(dirs[parser.AgentWindsurf]) + require.Len(t, targets, 1) + assert.Truef(t, strings.HasSuffix(targets[0], "AppData/Roaming/Windsurf/User/workspaceStorage"), + "windsurf target should resolve to workspaceStorage, got %v", targets) + assert.NotContains(t, targets, filepath.ToSlash(userRoot)) +} + +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" + diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go index f05fb07ab..a0fb35273 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() 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/sync/engine.go b/internal/sync/engine.go index c6d53c2c8..bcd3bbe72 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -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..a3e936a35 --- /dev/null +++ b/internal/sync/windsurf_integration_test.go @@ -0,0 +1,71 @@ +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 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) +} From cd4edb2a7591f8ff3d78105c4b931e700eba2a4c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 14:30:40 -0400 Subject: [PATCH 02/12] fix(sync): require Windsurf hash freshness VALID (fixed): #1 -- Windsurf same-size/same-mtime SQLite rewrites need to compare the provider file_hash before taking skip-cache or stored-freshness shortcuts. Windsurf now uses a component-labeled content digest for state.vscdb, WAL/SHM, and workspace.json so the fingerprint is stable across roots while still moving when parsed source content changes. The sync freshness allowlist now requires that hash for Windsurf, matching the composite fingerprint contract. The commit also includes the repository hook's automatic Qoder slices.Backward modernization; without staging that generated fix, the pre-commit hook re-applies it and aborts before the Windsurf fix can be committed. --- internal/parser/qoder.go | 7 +- internal/parser/windsurf_provider.go | 16 ++- internal/parser/windsurf_provider_test.go | 26 +++++ internal/sync/engine.go | 2 +- internal/sync/windsurf_integration_test.go | 122 +++++++++++++++++++++ 5 files changed, 165 insertions(+), 8 deletions(-) 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/windsurf_provider.go b/internal/parser/windsurf_provider.go index 7b6d4389b..4809bafc7 100644 --- a/internal/parser/windsurf_provider.go +++ b/internal/parser/windsurf_provider.go @@ -814,15 +814,23 @@ func windsurfWorkspaceProject(dbPath string) string { func windsurfSourceHash(dbPath, workspacePath string) (string, error) { h := sha256.New() - for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm", workspacePath} { - if path == "" || !IsRegularFile(path) { + for _, component := range []struct { + label string + path string + }{ + {label: "db", path: dbPath}, + {label: "wal", path: dbPath + "-wal"}, + {label: "shm", path: dbPath + "-shm"}, + {label: "workspace", path: workspacePath}, + } { + if component.path == "" || !IsRegularFile(component.path) { continue } - hash, err := hashJSONLSourceFile(path) + hash, err := hashJSONLSourceFile(component.path) if err != nil { return "", err } - _, _ = h.Write([]byte(path)) + _, _ = h.Write([]byte(component.label)) _, _ = h.Write([]byte{0}) _, _ = h.Write([]byte(hash)) _, _ = h.Write([]byte{0}) diff --git a/internal/parser/windsurf_provider_test.go b/internal/parser/windsurf_provider_test.go index f1efc5ce9..0b5f5edf8 100644 --- a/internal/parser/windsurf_provider_test.go +++ b/internal/parser/windsurf_provider_test.go @@ -58,6 +58,32 @@ func TestWindsurfProviderDiscoversAndParsesWorkspaceSQLiteChat(t *testing.T) { 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 TestWindsurfProviderParsesTabContainerChatData(t *testing.T) { root, _ := windsurfProviderFixture(t, `{ "tabs": [{ diff --git a/internal/sync/engine.go b/internal/sync/engine.go index bcd3bbe72..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 diff --git a/internal/sync/windsurf_integration_test.go b/internal/sync/windsurf_integration_test.go index a3e936a35..dbce2bed0 100644 --- a/internal/sync/windsurf_integration_test.go +++ b/internal/sync/windsurf_integration_test.go @@ -55,6 +55,85 @@ func TestSourceMtimeWindsurfUsesProviderFingerprint(t *testing.T) { 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() + + first := engine.processFile(context.Background(), parser.DiscoveredFile{ + Path: virtualPath, + Agent: parser.AgentWindsurf, + }) + require.NoError(t, first.err) + require.Len(t, first.results, 1) + require.Len(t, first.results[0].Messages, 2) + assert.Equal(t, "Alpha reply", first.results[0].Messages[1].Content) + initialMtime := first.results[0].Session.File.Mtime + initialHash := first.results[0].Session.File.Hash + require.NotZero(t, initialMtime) + require.NotEmpty(t, initialHash) + writeSyncWindsurfResult(t, engine, first) + + 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(first.cacheKey, 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) @@ -69,3 +148,46 @@ func writeSyncWindsurfStateDB(t *testing.T, dbPath, payload string) { ) 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 writeSyncWindsurfResult(t *testing.T, engine *Engine, result processResult) { + t.Helper() + require.Len(t, result.results, 1) + written, _, failed := engine.writeBatch( + []pendingWrite{{ + sess: result.results[0].Session, + msgs: result.results[0].Messages, + usageEvents: result.results[0].UsageEvents, + forceReplace: result.forceReplace, + }}, + syncWriteDefault, + false, + ) + require.Equal(t, 0, failed) + require.Equal(t, 1, written) +} + +func windsurfSyncPayload(sessionID, assistant string) string { + return `{ + "version": 1, + "sessionId": "` + sessionID + `", + "requests": [{ + "requestId": "request-1", + "message": {"text": "Question"}, + "response": [{"value": "` + assistant + `"}], + "timestamp": 1710000000000 + }] + }` +} From 08a401142403e2b87b110b4f0808233a78f39849 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 14:55:27 -0400 Subject: [PATCH 03/12] fix(remotesync): avoid archiving Windsurf workspace roots VALID (fixed): #1 -- Windsurf remote-sync targets exposed workspaceStorage as a recursive archive root. Windsurf targets now separate import roots from archived files, so the HTTP archive endpoint streams only state.vscdb, sidecars, and workspace.json. Dir-only archive requests for file-scoped Windsurf targets are rejected instead of falling back to recursive tar. --- internal/remotesync/archive.go | 12 ++- internal/remotesync/paths.go | 7 ++ internal/remotesync/resolve.go | 92 ++++++++++++++++--- internal/remotesync/resolve_test.go | 76 +++++++++++++-- internal/remotesync/types.go | 1 + .../huma_routes_remote_sync_internal_test.go | 74 +++++++++++++++ 6 files changed, 243 insertions(+), 19 deletions(-) diff --git a/internal/remotesync/archive.go b/internal/remotesync/archive.go index 001d32f91..46ba69af7 100644 --- a/internal/remotesync/archive.go +++ b/internal/remotesync/archive.go @@ -11,13 +11,23 @@ import ( 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 _, files := range targets.Files { + 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 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 03d00fe9f..dc82b454f 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) { @@ -26,9 +28,10 @@ func ResolveTargets(cfg config.Config) TargetSet { continue } if def.Type == parser.AgentWindsurf { - target := resolveWindsurfTarget(dir) - if target != "" { - dirs[def.Type] = append(dirs[def.Type], target) + 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 } @@ -46,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 { @@ -86,15 +89,60 @@ func resolveAiderTargets(root string) []string { return out } -func resolveWindsurfTarget(root string) string { - target := filepath.Clean(root) - if filepath.Base(target) != "workspaceStorage" { - target = filepath.Join(target, "workspaceStorage") +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 } - if info, err := os.Stat(target); err == nil && info.IsDir() { - return target + files := resolveWindsurfFiles(workspaceRoot) + if len(files) == 0 { + return "", nil } - return "" + 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", + dbPath + "-shm", + 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) + return err == nil && info.Mode().IsRegular() } func providerDiscoveredPath(source parser.SourceRef) string { @@ -121,6 +169,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 { @@ -129,6 +183,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 ce0a8c82f..a9cec42e2 100644 --- a/internal/remotesync/resolve_test.go +++ b/internal/remotesync/resolve_test.go @@ -27,13 +27,20 @@ func TestResolveTargetsFiltersAndIncludesSpecialFiles(t *testing.T) { 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) + 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(windsurfWorkspaceRoot, 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)) + 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)) @@ -57,8 +64,13 @@ 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{windsurfWorkspaceRoot}, targets.Dirs[parser.AgentWindsurf]) - assert.NotContains(t, targets.Dirs[parser.AgentWindsurf], windsurfUserRoot) + assert.Equal(t, []string{windsurfUserRoot}, targets.Dirs[parser.AgentWindsurf]) + assert.NotContains(t, targets.Dirs[parser.AgentWindsurf], windsurfWorkspaceRoot) + assert.ElementsMatch(t, []string{ + windsurfStateDB, + windsurfWorkspaceJSON, + }, targets.Files[parser.AgentWindsurf]) + assert.NotContains(t, targets.Files[parser.AgentWindsurf], windsurfSecret) assert.Contains(t, targets.ExtraFiles, codexIndex) } @@ -84,13 +96,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"}, } @@ -99,9 +124,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{ @@ -132,12 +184,17 @@ func TestResolveTargetsMatchesSSHResolverForRepresentativeHome(t *testing.T) { 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(windsurfWorkspaceRoot, 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)) @@ -164,6 +221,11 @@ 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, sshDirs[parser.AgentWindsurf], goTargets.Dirs[parser.AgentWindsurf]) + assert.ElementsMatch(t, []string{windsurfWorkspaceRoot}, sshDirs[parser.AgentWindsurf]) + assert.ElementsMatch(t, []string{windsurfUserRoot}, goTargets.Dirs[parser.AgentWindsurf]) + assert.ElementsMatch(t, []string{ + windsurfStateDB, + windsurfWorkspaceJSON, + }, goTargets.Files[parser.AgentWindsurf]) 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..7c2fad67c 100644 --- a/internal/server/huma_routes_remote_sync_internal_test.go +++ b/internal/server/huma_routes_remote_sync_internal_test.go @@ -19,6 +19,7 @@ import ( "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 +104,56 @@ func TestRemoteSyncArchiveStreamsTar(t *testing.T) { } } +func TestRemoteSyncArchiveWindsurfStreamsOnlySessionFiles(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)) + require.NoError(t, os.WriteFile(stateDB, []byte("state"), 0o644)) + 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()) + entries := tarEntryNames(t, archiveW.Body.Bytes()) + assert.True(t, hasTarEntrySuffix(entries, "workspace-a/"+parser.WindsurfStateDBName), "entries: %v", entries) + 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) +} + func pathBaseSlash(p string) string { i := strings.LastIndex(p, "/") if i < 0 { @@ -111,6 +162,29 @@ func pathBaseSlash(p string) string { return p[i+1:] } +func tarEntryNames(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 hasTarEntrySuffix(names []string, suffix string) bool { + for _, name := range names { + if strings.HasSuffix(name, suffix) { + return true + } + } + return false +} + func TestRemoteSyncArchiveDoesNotAppendErrorAfterStreamingStarts(t *testing.T) { srv, _, sessionPath := newRemoteSyncServer(t) targets := map[string]any{ From 44073657ab9e2c0ac466665073174c7a1630f2e1 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 15:56:56 -0400 Subject: [PATCH 04/12] fix(ssh): scope Windsurf remote sync files VALID (fixed): #1 -- SSH remote sync still resolved Windsurf to workspaceStorage and recursively tarred unrelated workspace files. The SSH resolver now emits the Windsurf import root separately from agent-scoped file records, so transfer can include only state.vscdb, WAL/SHM sidecars, and workspace.json while import keeps the same root metadata. This aligns the default SSH transport with the HTTP remote-sync file-scoped behavior and prevents dir-only Windsurf roots from being archived recursively. --- internal/remotesync/resolve.go | 5 +- internal/remotesync/resolve_test.go | 12 +++- internal/ssh/resolve.go | 100 +++++++++++++++++++++++----- internal/ssh/resolve_test.go | 47 ++++++++++--- internal/ssh/sync.go | 5 +- internal/ssh/transfer.go | 22 ++++-- internal/ssh/transfer_test.go | 21 +++++- 7 files changed, 174 insertions(+), 38 deletions(-) diff --git a/internal/remotesync/resolve.go b/internal/remotesync/resolve.go index dc82b454f..0c46fee3e 100644 --- a/internal/remotesync/resolve.go +++ b/internal/remotesync/resolve.go @@ -142,7 +142,10 @@ func resolveWindsurfFiles(workspaceRoot string) []string { func regularRemoteSyncFile(path string) bool { info, err := os.Lstat(path) - return err == nil && info.Mode().IsRegular() + if err != nil { + return false + } + return info.Mode().IsRegular() } func providerDiscoveredPath(source parser.SourceRef) string { diff --git a/internal/remotesync/resolve_test.go b/internal/remotesync/resolve_test.go index a9cec42e2..5a6896999 100644 --- a/internal/remotesync/resolve_test.go +++ b/internal/remotesync/resolve_test.go @@ -203,7 +203,7 @@ func TestResolveTargetsMatchesSSHResolverForRepresentativeHome(t *testing.T) { 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{ @@ -221,11 +221,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{windsurfWorkspaceRoot}, sshDirs[parser.AgentWindsurf]) - assert.ElementsMatch(t, []string{windsurfUserRoot}, goTargets.Dirs[parser.AgentWindsurf]) + 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/ssh/resolve.go b/internal/ssh/resolve.go index 20b4b68e1..40fd86ee1 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,12 +78,38 @@ func buildAiderResolveSnippet(envVar string) string { func buildResolveScript() string { var b strings.Builder b.WriteString( - "av_emit_target() { " + + "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_db-shm\" \"$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 " + - "case \"$target\" in */workspaceStorage) ;; " + - "*) target=\"$target/workspaceStorage\";; esac; " + + "av_emit_windsurf_target \"$target\"; " + + "return; " + "fi; " + "[ -d \"$target\" ] && printf '%s\\000' \"$agent:$target\"; " + "}\n" + @@ -193,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 == "" { @@ -224,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() { @@ -231,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 } @@ -240,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) @@ -257,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 := 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 dbccaabbf..5fcb51d77 100644 --- a/internal/ssh/resolve_test.go +++ b/internal/ssh/resolve_test.go @@ -257,20 +257,33 @@ func TestResolveScriptAiderRejectsHomeOverride(t *testing.T) { } } -func TestResolveScriptWindsurfTargetsWorkspaceStorage(t *testing.T) { +func TestResolveScriptWindsurfTargetsOnlySessionFiles(t *testing.T) { home := t.TempDir() userRoot := filepath.Join(home, "AppData", "Roaming", "Windsurf", "User") workspaceRoot := filepath.Join(userRoot, "workspaceStorage") - require.NoError(t, os.MkdirAll(workspaceRoot, 0o755)) + 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) - dirs, _ := parseResolvedDirs(string(out)) - targets := slashPaths(dirs[parser.AgentWindsurf]) - require.Len(t, targets, 1) - assert.Truef(t, strings.HasSuffix(targets[0], "AppData/Roaming/Windsurf/User/workspaceStorage"), - "windsurf target should resolve to workspaceStorage, got %v", targets) - assert.NotContains(t, targets, filepath.ToSlash(userRoot)) + records := resolveOutputRecords(string(out)) + assert.Contains(t, records, string(parser.AgentWindsurf)+":"+filepath.ToSlash(userRoot)) + assert.Contains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(stateDB)) + assert.Contains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(stateWAL)) + assert.Contains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(stateSHM)) + assert.Contains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(workspaceJSON)) + assert.NotContains(t, records, string(parser.AgentWindsurf)+":"+filepath.ToSlash(workspaceRoot)) + assert.NotContains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(secretPath)) } func runResolveScriptForTest(t *testing.T, env ...string) []byte { @@ -322,3 +335,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/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..f470e7446 100644 --- a/internal/ssh/transfer.go +++ b/internal/ssh/transfer.go @@ -15,20 +15,29 @@ import ( ) // 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. +// agent directories, agent-scoped files, and extra files. Uses -C / +// so paths are relative to root. Strips leading / from each path and +// shell-quotes it. The file paths are resolved to exist on the remote +// (see buildResolveScript), so tar does not fail on a missing path. 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, "/"))) } } + for _, agentFiles := range files { + for _, f := range agentFiles { + paths = append(paths, shellQuote(strings.TrimPrefix(f, "/"))) + } + } for _, f := range extraFiles { paths = append(paths, shellQuote(strings.TrimPrefix(f, "/"))) } @@ -47,9 +56,10 @@ 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) + tarCmd := buildTarCommand(dirs, files, extraFiles) stdout, cleanup, err := runSSHStream( ctx, host, user, port, sshOpts, tarCmd, ) diff --git a/internal/ssh/transfer_test.go b/internal/ssh/transfer_test.go index 83666f32e..e8911e5c6 100644 --- a/internal/ssh/transfer_test.go +++ b/internal/ssh/transfer_test.go @@ -14,7 +14,7 @@ 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. @@ -26,6 +26,25 @@ func TestBuildTarCommand(t *testing.T) { 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 TestRemapPath(t *testing.T) { // Use filepath.Join so the local paths are OS-native. // remapToRemotePath always returns forward-slash paths. From 95fec13771fc00b3b4531438d64d0c33a21011ca Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 16:06:51 -0400 Subject: [PATCH 05/12] test(ssh): tolerate MSYS paths in Windsurf resolver test Windows CI runs the SSH resolver script through MSYS sh, which rewrites native temp paths from C:/... to /c/.... The test was asserting exact native drive-letter records even though the behavior under test is the resolver record type and the Windsurf path shape. Match record prefixes and path suffixes instead, preserving coverage that only the Windsurf root and allowed session files are emitted while making the assertion independent of the shell path dialect. --- internal/ssh/resolve_test.go | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/internal/ssh/resolve_test.go b/internal/ssh/resolve_test.go index 5fcb51d77..629fbb623 100644 --- a/internal/ssh/resolve_test.go +++ b/internal/ssh/resolve_test.go @@ -277,13 +277,31 @@ func TestResolveScriptWindsurfTargetsOnlySessionFiles(t *testing.T) { out := runResolveScriptForTest(t, "HOME="+home) records := resolveOutputRecords(string(out)) - assert.Contains(t, records, string(parser.AgentWindsurf)+":"+filepath.ToSlash(userRoot)) - assert.Contains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(stateDB)) - assert.Contains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(stateWAL)) - assert.Contains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(stateSHM)) - assert.Contains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(workspaceJSON)) - assert.NotContains(t, records, string(parser.AgentWindsurf)+":"+filepath.ToSlash(workspaceRoot)) - assert.NotContains(t, records, resolveAgentFilePrefix+":"+string(parser.AgentWindsurf)+":"+filepath.ToSlash(secretPath)) + 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.True(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 { From d8b16c32bb40199d26596c1b0e27282d801a6ca0 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 16:45:32 -0400 Subject: [PATCH 06/12] fix(ssh): stream remote tar path lists VALID (fixed): #1 -- SSH Windsurf remote sync expanded all file-scoped targets into one tar argv list. The SSH transfer now sends the tar script over stdin and feeds selected paths to tar with -T -, so large Windsurf exports do not consume ssh/exec argument space. Message-write benchmarks also silence slow-operation logs because those logs can interleave with benchmark rows and leave benchgate with too few parseable candidate samples. --- internal/db/messages_bench_test.go | 13 +++++ internal/ssh/ssh.go | 11 ++--- internal/ssh/transfer.go | 22 ++++++--- internal/ssh/transfer_test.go | 76 ++++++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 16 deletions(-) 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/ssh/ssh.go b/internal/ssh/ssh.go index a0fb35273..c0ee085ce 100644 --- a/internal/ssh/ssh.go +++ b/internal/ssh/ssh.go @@ -102,20 +102,17 @@ func runSSHScript( 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/transfer.go b/internal/ssh/transfer.go index f470e7446..487df7130 100644 --- a/internal/ssh/transfer.go +++ b/internal/ssh/transfer.go @@ -14,11 +14,12 @@ import ( "go.kenn.io/agentsview/internal/remotesync" ) -// buildTarCommand generates the remote tar command for the given +// 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. Strips leading / from each path and -// shell-quotes it. The file paths are resolved to exist on the remote -// (see buildResolveScript), so tar does not fail on a missing path. +// 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, @@ -41,7 +42,16 @@ func buildTarCommand( for _, f := range extraFiles { paths = append(paths, shellQuote(strings.TrimPrefix(f, "/"))) } - return "tar cf - -C / -- " + strings.Join(paths, " ") + var b strings.Builder + b.WriteString("set -e\n{\n") + b.WriteString(":\n") + for _, path := range paths { + b.WriteString("printf '%s\\n' ") + b.WriteString(path) + b.WriteByte('\n') + } + b.WriteString("} | tar cf - -C / -T -\n") + return b.String() } // shellQuote wraps s in single quotes, escaping any embedded @@ -60,7 +70,7 @@ func downloadAndExtract( extraFiles []string, ) (string, error) { tarCmd := buildTarCommand(dirs, files, extraFiles) - stdout, cleanup, err := runSSHStream( + 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 e8911e5c6..19f311ef8 100644 --- a/internal/ssh/transfer_test.go +++ b/internal/ssh/transfer_test.go @@ -1,11 +1,17 @@ package ssh import ( + "archive/tar" + "bytes" + "io" + "os" + "os/exec" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.kenn.io/agentsview/internal/parser" ) @@ -16,11 +22,13 @@ func TestBuildTarCommand(t *testing.T) { } 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, "| 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. 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. + // 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) @@ -45,6 +53,68 @@ func TestBuildTarCommandSkipsFileScopedWindsurfDirs(t *testing.T) { "file-scoped Windsurf root must not be archived recursively: %s", cmd) } +func TestBuildTarCommandStreamsPathListToTar(t *testing.T) { + 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 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. From cba5b2c01780b3bc3fa2bc6aa39fda35db16d147 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 16:56:41 -0400 Subject: [PATCH 07/12] fix(benchgate): tolerate partial baseline captures Baseline benchmarks run from the merge base, so they can predate candidate fixes that keep benchmark output parseable. Treat baseline syntax errors like other partial baseline conditions: report them and gate the parseable data, while candidate syntax errors remain a hard configuration failure. --- cmd/benchgate/main.go | 19 +++++++++++-------- cmd/benchgate/main_test.go | 28 +++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 9 deletions(-) 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}, From 360fc2e17807bac66c281ed81da478c1ecf88ca8 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 17:06:45 -0400 Subject: [PATCH 08/12] test(ssh): skip POSIX tar execution on Windows The SSH tar script is executed by a POSIX remote shell, but the unit test runs it locally. On Windows, MSYS sh receives native temp paths that do not represent remote POSIX paths, so keep the structural coverage there and run the end-to-end script execution only on Unix-like hosts. --- internal/ssh/transfer_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/ssh/transfer_test.go b/internal/ssh/transfer_test.go index 19f311ef8..5c197ee46 100644 --- a/internal/ssh/transfer_test.go +++ b/internal/ssh/transfer_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -54,6 +55,10 @@ func TestBuildTarCommandSkipsFileScopedWindsurfDirs(t *testing.T) { } 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") From f3dc208dadb808348268cde781e60e44df15ca71 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 18:18:02 -0400 Subject: [PATCH 09/12] fix(remotesync): sanitize Windsurf archives VALID (fixed): #1 -- HTTP remote sync could stream raw Windsurf workspace SQLite state and sidecars to any bearer-token holder allowed to request remote archives. VALID (fixed): #2 -- Windsurf freshness and remote target selection treated transient SQLite SHM files as durable session inputs. Windsurf HTTP archives now synthesize a filtered state.vscdb containing only the chat keys the parser consumes, while preserving workspace metadata for project naming. SHM is no longer watched, fingerprinted, or resolved, and SSH tar input skips paths that disappear after resolution so optional WAL sidecars do not abort transfer. --- internal/parser/windsurf_provider.go | 156 ++++++++++++++---- internal/parser/windsurf_provider_test.go | 82 ++++++++- internal/remotesync/archive.go | 136 ++++++++++++++- internal/remotesync/resolve.go | 1 - internal/remotesync/resolve_test.go | 6 + .../huma_routes_remote_sync_internal_test.go | 108 ++++++++++-- internal/ssh/resolve.go | 2 +- internal/ssh/resolve_test.go | 2 +- internal/ssh/transfer.go | 6 +- internal/ssh/transfer_test.go | 30 ++++ 10 files changed, 473 insertions(+), 56 deletions(-) diff --git a/internal/parser/windsurf_provider.go b/internal/parser/windsurf_provider.go index 4809bafc7..9e5f531e8 100644 --- a/internal/parser/windsurf_provider.go +++ b/internal/parser/windsurf_provider.go @@ -199,7 +199,7 @@ func (s windsurfSourceSet) WatchPlan(context.Context) (WatchPlan, error) { roots = append(roots, WatchRoot{ Path: workspace, Recursive: true, - IncludeGlobs: []string{windsurfStateDBName, windsurfStateDBName + "-*", "workspace.json"}, + IncludeGlobs: []string{windsurfStateDBName, windsurfStateDBName + "-wal", "workspace.json"}, DebounceKey: string(AgentWindsurf) + ":workspace:" + workspace, }) } @@ -320,7 +320,6 @@ func (s windsurfSourceSet) Fingerprint( combined := antigravityCLICombinedFileInfo( info, src.DBPath+"-wal", - src.DBPath+"-shm", workspacePath, ) hash, err := windsurfSourceHash(src.DBPath, workspacePath) @@ -429,7 +428,7 @@ func (s windsurfSourceSet) dbPathForEvent( return "", false } switch parts[1] { - case windsurfStateDBName, windsurfStateDBName + "-wal", windsurfStateDBName + "-shm", "workspace.json": + case windsurfStateDBName, windsurfStateDBName + "-wal", "workspace.json": return filepath.Join(workspaceRoot, parts[0], windsurfStateDBName), true default: return "", false @@ -493,32 +492,21 @@ type windsurfSessionRecord struct { Data []byte } +type windsurfChatValue struct { + Key string + Value string +} + func listWindsurfSessionRecords(dbPath string) ([]windsurfSessionRecord, error) { - if _, err := os.Stat(dbPath); os.IsNotExist(err) { - return nil, nil - } - db, err := openWindsurfDB(dbPath) + values, err := readWindsurfChatValues(dbPath) if err != nil { return nil, err } - defer db.Close() - seen := make(map[string]struct{}) var records []windsurfSessionRecord - 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) - } + for _, value := range values { next, err := windsurfRecordsFromValue( - []byte(value), + []byte(value.Value), windsurfFallbackSessionID(dbPath), ) if err != nil { @@ -538,6 +526,37 @@ func listWindsurfSessionRecords(dbPath string) ([]windsurfSessionRecord, error) 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 { @@ -577,7 +596,6 @@ func parseWindsurfSession( combined := antigravityCLICombinedFileInfo( info, dbPath+"-wal", - dbPath+"-shm", windsurfWorkspaceManifestPath(dbPath), ) sess.Agent = AgentWindsurf @@ -800,6 +818,69 @@ func WriteWindsurfSessionJSON(w io.Writer, dbPath, sessionID string) error { 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") } @@ -814,23 +895,26 @@ func windsurfWorkspaceProject(dbPath string) string { func windsurfSourceHash(dbPath, workspacePath string) (string, error) { h := sha256.New() - for _, component := range []struct { - label string - path string - }{ - {label: "db", path: dbPath}, - {label: "wal", path: dbPath + "-wal"}, - {label: "shm", path: dbPath + "-shm"}, - {label: "workspace", path: workspacePath}, - } { - if component.path == "" || !IsRegularFile(component.path) { - continue + if IsRegularFile(dbPath) { + values, err := readWindsurfChatValues(dbPath) + if err != nil { + return "", err } - hash, err := hashJSONLSourceFile(component.path) + 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(component.label)) + _, _ = h.Write([]byte("workspace")) _, _ = h.Write([]byte{0}) _, _ = h.Write([]byte(hash)) _, _ = h.Write([]byte{0}) diff --git a/internal/parser/windsurf_provider_test.go b/internal/parser/windsurf_provider_test.go index 0b5f5edf8..91a69f120 100644 --- a/internal/parser/windsurf_provider_test.go +++ b/internal/parser/windsurf_provider_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/assert" @@ -84,6 +85,34 @@ func TestWindsurfProviderFingerprintHashIsContentBased(t *testing.T) { 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": [{ @@ -314,7 +343,9 @@ func TestWindsurfProviderWatchPlan(t *testing.T) { 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-*") + 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") } @@ -354,6 +385,35 @@ func TestWriteWindsurfSessionJSONScopesVirtualSource(t *testing.T) { 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}, @@ -389,6 +449,26 @@ func writeWindsurfStateDB(t *testing.T, dbPath, payload string) { 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, diff --git a/internal/remotesync/archive.go b/internal/remotesync/archive.go index 46ba69af7..b6c1971ae 100644 --- a/internal/remotesync/archive.go +++ b/internal/remotesync/archive.go @@ -7,6 +7,9 @@ import ( "io/fs" "os" "path/filepath" + "time" + + "go.kenn.io/agentsview/internal/parser" ) func WriteArchive(w io.Writer, targets TargetSet) error { @@ -21,7 +24,13 @@ func WriteArchive(w io.Writer, targets TargetSet) error { } } } - for _, files := range targets.Files { + 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 @@ -39,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 { @@ -103,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/resolve.go b/internal/remotesync/resolve.go index 0c46fee3e..c05e25c7f 100644 --- a/internal/remotesync/resolve.go +++ b/internal/remotesync/resolve.go @@ -128,7 +128,6 @@ func resolveWindsurfFiles(workspaceRoot string) []string { files = append(files, dbPath) for _, path := range []string{ dbPath + "-wal", - dbPath + "-shm", filepath.Join(workspaceDir, "workspace.json"), } { if regularRemoteSyncFile(path) { diff --git a/internal/remotesync/resolve_test.go b/internal/remotesync/resolve_test.go index 5a6896999..9fc99a7a0 100644 --- a/internal/remotesync/resolve_test.go +++ b/internal/remotesync/resolve_test.go @@ -29,6 +29,8 @@ func TestResolveTargetsFiltersAndIncludesSpecialFiles(t *testing.T) { 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)) @@ -39,6 +41,8 @@ func TestResolveTargetsFiltersAndIncludesSpecialFiles(t *testing.T) { 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) @@ -68,8 +72,10 @@ func TestResolveTargetsFiltersAndIncludesSpecialFiles(t *testing.T) { 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) } diff --git a/internal/server/huma_routes_remote_sync_internal_test.go b/internal/server/huma_routes_remote_sync_internal_test.go index 7c2fad67c..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,6 +15,7 @@ import ( "testing" "time" + _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/agentsview/internal/config" @@ -104,7 +106,7 @@ func TestRemoteSyncArchiveStreamsTar(t *testing.T) { } } -func TestRemoteSyncArchiveWindsurfStreamsOnlySessionFiles(t *testing.T) { +func TestRemoteSyncArchiveWindsurfStreamsSanitizedStateDB(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") database := dbtest.OpenTestDBAt(t, dbPath) @@ -114,7 +116,8 @@ func TestRemoteSyncArchiveWindsurfStreamsOnlySessionFiles(t *testing.T) { 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)) + 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{ @@ -148,10 +151,17 @@ func TestRemoteSyncArchiveWindsurfStreamsOnlySessionFiles(t *testing.T) { handler.ServeHTTP(archiveW, archiveReq) require.Equal(t, http.StatusOK, archiveW.Code, "body: %s", archiveW.Body.String()) - entries := tarEntryNames(t, archiveW.Body.Bytes()) - assert.True(t, hasTarEntrySuffix(entries, "workspace-a/"+parser.WindsurfStateDBName), "entries: %v", entries) + 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 { @@ -162,27 +172,99 @@ func pathBaseSlash(p string) string { return p[i+1:] } -func tarEntryNames(t *testing.T, archive []byte) []string { +type tarTestEntry struct { + Name string + Body []byte +} + +func tarEntries(t *testing.T, archive []byte) []tarTestEntry { t.Helper() tr := tar.NewReader(bytes.NewReader(archive)) - var names []string + var entries []tarTestEntry for { hdr, err := tr.Next() if err == io.EOF { - return names + return entries } require.NoError(t, err) - names = append(names, hdr.Name) + 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(names []string, suffix string) bool { - for _, name := range names { - if strings.HasSuffix(name, suffix) { - return true +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 false + 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) { diff --git a/internal/ssh/resolve.go b/internal/ssh/resolve.go index 40fd86ee1..718753f4a 100644 --- a/internal/ssh/resolve.go +++ b/internal/ssh/resolve.go @@ -99,7 +99,7 @@ func buildResolveScript() string { "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_db-shm\" \"$av_windsurf_ws/workspace.json\"; do " + + "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; " + diff --git a/internal/ssh/resolve_test.go b/internal/ssh/resolve_test.go index 629fbb623..c03695f17 100644 --- a/internal/ssh/resolve_test.go +++ b/internal/ssh/resolve_test.go @@ -286,7 +286,7 @@ func TestResolveScriptWindsurfTargetsOnlySessionFiles(t *testing.T) { filepath.ToSlash(filepath.Join(workspaceSuffix, parser.WindsurfStateDBName)))) assert.True(t, hasRecordWithPathSuffix(records, agentFilePrefix, filepath.ToSlash(filepath.Join(workspaceSuffix, parser.WindsurfStateDBName+"-wal")))) - assert.True(t, hasRecordWithPathSuffix(records, agentFilePrefix, + 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")))) diff --git a/internal/ssh/transfer.go b/internal/ssh/transfer.go index 487df7130..b087d83dc 100644 --- a/internal/ssh/transfer.go +++ b/internal/ssh/transfer.go @@ -43,10 +43,12 @@ func buildTarCommand( paths = append(paths, shellQuote(strings.TrimPrefix(f, "/"))) } var b strings.Builder - b.WriteString("set -e\n{\n") + 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("printf '%s\\n' ") + b.WriteString("av_emit_tar_path ") b.WriteString(path) b.WriteByte('\n') } diff --git a/internal/ssh/transfer_test.go b/internal/ssh/transfer_test.go index 5c197ee46..df1908595 100644 --- a/internal/ssh/transfer_test.go +++ b/internal/ssh/transfer_test.go @@ -91,6 +91,36 @@ func TestBuildTarCommandStreamsPathListToTar(t *testing.T) { 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 tarCommandLine(t *testing.T, script string) string { t.Helper() for line := range strings.SplitSeq(script, "\n") { From 19f29b46aa2e07a794500492b1a61c15d613c78a Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 18:24:41 -0400 Subject: [PATCH 10/12] test(sync): avoid private Windsurf write helper The Windsurf freshness regression test only needed an initial stored session, but it reached into writeBatch directly. Current main changed that private helper's return shape, so the PR merge ref failed to compile even though the branch alone still built. Seed the initial row through SyncAll and derive the old hash-scoped skip-cache key from stored session metadata instead. That keeps the test focused on Windsurf freshness behavior and avoids coupling it to writeBatch internals. --- internal/sync/windsurf_integration_test.go | 54 +++++++++++----------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/internal/sync/windsurf_integration_test.go b/internal/sync/windsurf_integration_test.go index dbce2bed0..8cbf2d223 100644 --- a/internal/sync/windsurf_integration_test.go +++ b/internal/sync/windsurf_integration_test.go @@ -82,19 +82,9 @@ func TestProcessFileWindsurfSameMtimeHashChangeReparses(t *testing.T) { }) defer engine.Close() - first := engine.processFile(context.Background(), parser.DiscoveredFile{ - Path: virtualPath, - Agent: parser.AgentWindsurf, - }) - require.NoError(t, first.err) - require.Len(t, first.results, 1) - require.Len(t, first.results[0].Messages, 2) - assert.Equal(t, "Alpha reply", first.results[0].Messages[1].Content) - initialMtime := first.results[0].Session.File.Mtime - initialHash := first.results[0].Session.File.Hash - require.NotZero(t, initialMtime) - require.NotEmpty(t, initialHash) - writeSyncWindsurfResult(t, engine, first) + initialMtime, initialHash := syncInitialWindsurfSession( + t, engine, "hash-session", + ) infoBefore, err := os.Stat(dbPath) require.NoError(t, err) @@ -107,7 +97,14 @@ func TestProcessFileWindsurfSameMtimeHashChangeReparses(t *testing.T) { "test must keep size stable so hash is the only freshness signal") if tt.seedCache { - engine.cacheSkip(first.cacheKey, initialMtime) + engine.cacheSkip( + providerProcessCacheKeyWithHash( + virtualPath, + parser.AgentWindsurf, + parser.SourceFingerprint{Hash: initialHash}, + ), + initialMtime, + ) } if tt.freshSync { engine.Close() @@ -162,21 +159,24 @@ func updateSyncWindsurfStateDB(t *testing.T, dbPath, payload string) { require.NoError(t, err) } -func writeSyncWindsurfResult(t *testing.T, engine *Engine, result processResult) { +func syncInitialWindsurfSession( + t *testing.T, + engine *Engine, + sessionID string, +) (int64, string) { t.Helper() - require.Len(t, result.results, 1) - written, _, failed := engine.writeBatch( - []pendingWrite{{ - sess: result.results[0].Session, - msgs: result.results[0].Messages, - usageEvents: result.results[0].UsageEvents, - forceReplace: result.forceReplace, - }}, - syncWriteDefault, - false, + stats := engine.SyncAll(context.Background(), nil) + require.Equal(t, 1, stats.Synced) + sess, err := engine.db.GetSessionFull( + context.Background(), "windsurf:"+sessionID, ) - require.Equal(t, 0, failed) - require.Equal(t, 1, written) + 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 { From e650e310719b5fc0db54f8bc9e5596890c748c1b Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 18:49:55 -0400 Subject: [PATCH 11/12] fix(ssh): protect tar file-list paths VALID (fixed): #1 -- SSH tar path-list mode could treat a dash-leading relative archive input as a tar option. Prefix streamed entries with ./ before they reach tar -T -, preserving root-relative behavior under -C / while keeping file-list parsing safe on GNU tar. --- internal/ssh/transfer.go | 23 ++++++++++++++++++++--- internal/ssh/transfer_test.go | 24 ++++++++++++++++-------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/internal/ssh/transfer.go b/internal/ssh/transfer.go index b087d83dc..b571be863 100644 --- a/internal/ssh/transfer.go +++ b/internal/ssh/transfer.go @@ -31,16 +31,22 @@ func buildTarCommand( 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 { - paths = append(paths, shellQuote(strings.TrimPrefix(f, "/"))) + 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") @@ -56,6 +62,17 @@ func buildTarCommand( return b.String() } +func tarListPath(path string) string { + rel := strings.TrimPrefix(path, "/") + if rel == "" || rel == "." { + return "" + } + if strings.HasPrefix(rel, "./") { + return rel + } + return "./" + rel +} + // shellQuote wraps s in single quotes, escaping any embedded // single quotes. Safe for passing paths through sh -c. func shellQuote(s string) string { diff --git a/internal/ssh/transfer_test.go b/internal/ssh/transfer_test.go index df1908595..6a4ab451c 100644 --- a/internal/ssh/transfer_test.go +++ b/internal/ssh/transfer_test.go @@ -26,11 +26,12 @@ func TestBuildTarCommand(t *testing.T) { 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. - assert.Contains(t, cmd, "'home/wes/.claude/projects'") - assert.Contains(t, cmd, "'home/wes/.codex/sessions'") + // 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'") + 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) } @@ -48,12 +49,19 @@ func TestBuildTarCommandSkipsFileScopedWindsurfDirs(t *testing.T) { 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'", + 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("/")) +} + func TestBuildTarCommandStreamsPathListToTar(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("remote tar script uses POSIX paths; local Windows paths are not representative") @@ -147,7 +155,7 @@ func tarNames(t *testing.T, archive []byte) []string { } func archivePathForTest(path string) string { - return strings.TrimPrefix(filepath.ToSlash(path), "/") + return "./" + strings.TrimPrefix(filepath.ToSlash(path), "/") } func TestRemapPath(t *testing.T) { From 234f4113e3a7e691495e04e427533bc0ebaf125d Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 7 Jul 2026 21:07:58 -0400 Subject: [PATCH 12/12] fix(ssh): skip unsafe tar list paths VALID (fixed): #1 -- SSH tar path-list mode could split newline-bearing resolved paths into multiple tar entries. Reject CR/LF/NUL target paths before generating the remote shell script. This keeps newline-delimited tar -T input from reinterpreting one existing path as multiple archive requests while preserving the portable tar invocation used for normal paths. --- internal/ssh/transfer.go | 3 +++ internal/ssh/transfer_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/internal/ssh/transfer.go b/internal/ssh/transfer.go index b571be863..ebd716257 100644 --- a/internal/ssh/transfer.go +++ b/internal/ssh/transfer.go @@ -63,6 +63,9 @@ func buildTarCommand( } func tarListPath(path string) string { + if strings.ContainsAny(path, "\x00\n\r") { + return "" + } rel := strings.TrimPrefix(path, "/") if rel == "" || rel == "." { return "" diff --git a/internal/ssh/transfer_test.go b/internal/ssh/transfer_test.go index 6a4ab451c..123d1e0b6 100644 --- a/internal/ssh/transfer_test.go +++ b/internal/ssh/transfer_test.go @@ -60,6 +60,9 @@ func TestTarListPathProtectsOptionShapedPath(t *testing.T) { 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) { @@ -129,6 +132,29 @@ func TestBuildTarCommandSkipsMissingFileScopedPath(t *testing.T) { 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") {