Skip to content

Commit 95d652e

Browse files
committed
fix: validate scoped aider exports and discovery
1 parent 671d3b8 commit 95d652e

5 files changed

Lines changed: 188 additions & 26 deletions

File tree

cmd/agentsview/session_export.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"os"
11+
"strings"
1112

1213
"github.com/spf13/cobra"
1314
"go.kenn.io/agentsview/internal/config"
@@ -69,6 +70,33 @@ func newSessionExportCommand() *cobra.Command {
6970
// same repository.
7071
if historyPath, idx, ok :=
7172
parser.ParseAiderVirtualPath(storedPath); ok {
73+
rawID, ok := rawAiderSessionID(id)
74+
if !ok {
75+
return fmt.Errorf(
76+
"stale aider source for session %s: invalid aider session id",
77+
id,
78+
)
79+
}
80+
if got, ok := parser.AiderRawIDAt(historyPath, idx); !ok || got != rawID {
81+
if _, statErr := os.Stat(historyPath); statErr != nil {
82+
if os.IsNotExist(statErr) {
83+
return fmt.Errorf(
84+
"source file not found: %s", historyPath,
85+
)
86+
}
87+
return statErr
88+
}
89+
resolved, found := parser.AiderVirtualPathForRawID(
90+
historyPath, rawID,
91+
)
92+
if !found {
93+
return fmt.Errorf(
94+
"stale aider source for session %s: %s no longer contains the archived run",
95+
id, historyPath,
96+
)
97+
}
98+
historyPath, idx, _ = parser.ParseAiderVirtualPath(resolved)
99+
}
72100
err := parser.WriteAiderRunMarkdown(
73101
cmd.OutOrStdout(), historyPath, idx,
74102
)
@@ -110,3 +138,13 @@ func newSessionExportCommand() *cobra.Command {
110138
},
111139
}
112140
}
141+
142+
func rawAiderSessionID(sessionID string) (string, bool) {
143+
def, ok := parser.AgentByPrefix(sessionID)
144+
if !ok || def.Type != parser.AgentAider {
145+
return "", false
146+
}
147+
_, rawID := parser.StripHostPrefix(sessionID)
148+
rawID = strings.TrimPrefix(rawID, def.IDPrefix)
149+
return rawID, rawID != ""
150+
}

cmd/agentsview/session_test.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -690,22 +690,60 @@ func TestSessionExport_AiderVirtualPathStreamsOnlySelectedRun(t *testing.T) {
690690
require.NoError(t, os.WriteFile(
691691
history, []byte("ignored preamble\n"+run0+run1+run2), 0o600,
692692
))
693+
rawID, ok := parser.AiderRawIDAt(history, 1)
694+
require.True(t, ok, "run 1 raw ID")
693695

694-
seedSessionWithOpts(t, dataDir, "s-aider", "repo",
696+
seedSessionWithOpts(t, dataDir, "aider:"+rawID, "repo",
695697
func(s *db.Session) {
696698
s.Agent = string(parser.AgentAider)
697699
vp := parser.AiderVirtualPath(history, 1)
698700
s.FilePath = &vp
699701
})
700702

701703
out, err := executeCommand(newRootCommand(),
702-
"session", "export", "s-aider")
704+
"session", "export", "aider:"+rawID)
703705
require.NoError(t, err)
704706
assert.Equal(t, run1, out)
705707
assert.NotContains(t, out, "first prompt")
706708
assert.NotContains(t, out, "third prompt")
707709
}
708710

711+
func TestSessionExport_AiderStaleIndexReResolvesBySessionID(t *testing.T) {
712+
dataDir := t.TempDir()
713+
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
714+
715+
repo := filepath.Join(t.TempDir(), "repo")
716+
require.NoError(t, os.MkdirAll(repo, 0o755))
717+
history := filepath.Join(repo, parser.AiderHistoryFileName())
718+
run0 := "# aider chat started at 2026-06-09 14:01:00\n" +
719+
"#### first prompt\nanswer one\n"
720+
run1 := "# aider chat started at 2026-06-09 15:30:00\n" +
721+
"#### second prompt\nanswer two\n"
722+
require.NoError(t, os.WriteFile(history, []byte(run0+run1), 0o600))
723+
rawID, ok := parser.AiderRawIDAt(history, 1)
724+
require.True(t, ok, "run 1 raw ID")
725+
726+
seedSessionWithOpts(t, dataDir, "aider:"+rawID, "repo",
727+
func(s *db.Session) {
728+
s.Agent = string(parser.AgentAider)
729+
vp := parser.AiderVirtualPath(history, 1)
730+
s.FilePath = &vp
731+
})
732+
733+
inserted := "# aider chat started at 2026-06-09 13:00:00\n" +
734+
"#### inserted prompt\ninserted answer\n"
735+
require.NoError(t, os.WriteFile(
736+
history, []byte(inserted+run0+run1), 0o600,
737+
))
738+
739+
out, err := executeCommand(newRootCommand(),
740+
"session", "export", "aider:"+rawID)
741+
require.NoError(t, err)
742+
assert.Equal(t, run1, out)
743+
assert.NotContains(t, out, "inserted prompt")
744+
assert.NotContains(t, out, "first prompt")
745+
}
746+
709747
func TestSessionExport_FailsWhenSourceMissing(t *testing.T) {
710748
dataDir := t.TempDir()
711749
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)

internal/parser/aider.go

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,29 @@ var aiderSkipDirs = map[string]struct{}{
8989
".hg": {},
9090
}
9191

92+
// AiderDiscoverySkipDirNames returns the directory basenames pruned by Aider
93+
// discovery. Remote SSH discovery uses this to mirror local discovery semantics.
94+
func AiderDiscoverySkipDirNames() []string {
95+
names := make([]string, 0, len(aiderSkipDirs))
96+
for name := range aiderSkipDirs {
97+
names = append(names, name)
98+
}
99+
sort.Strings(names)
100+
return names
101+
}
102+
103+
// AiderDiscoveryMaxWalkDepth returns the maximum directory depth local Aider
104+
// discovery descends below the configured root.
105+
func AiderDiscoveryMaxWalkDepth() int { return aiderMaxWalkDepth }
106+
107+
// AiderDiscoveryMaxFiles returns the maximum number of Aider history files
108+
// local discovery returns from one configured root.
109+
func AiderDiscoveryMaxFiles() int { return aiderMaxFiles }
110+
111+
// AiderDiscoveryMaxDirs returns the maximum number of directories local Aider
112+
// discovery visits below one configured root.
113+
func AiderDiscoveryMaxDirs() int { return aiderMaxDirs }
114+
92115
// AiderHistoryFileName returns the fixed Markdown filename aider writes
93116
// per repo (".aider.chat.history.md"). The sync engine uses it to match
94117
// watched files back to the aider agent.
@@ -429,6 +452,32 @@ func AiderRawIDAt(historyPath string, idx int) (string, bool) {
429452
return aiderRawID(aiderAbsPath(historyPath), runs[idx].rawHeader, ordinals[idx]), true
430453
}
431454

455+
// AiderVirtualPathForRawID resolves rawID to its current positional virtual
456+
// path within one physical history file. This is used when a previously stored
457+
// "<history>#<idx>" path has gone stale because an earlier run was inserted or
458+
// removed after the last sync.
459+
func AiderVirtualPathForRawID(historyPath, rawID string) (string, bool) {
460+
if historyPath == "" || rawID == "" {
461+
return "", false
462+
}
463+
data, err := os.ReadFile(historyPath)
464+
if err != nil {
465+
return "", false
466+
}
467+
runs := splitAiderRuns(string(data))
468+
if len(runs) == 0 {
469+
return "", false
470+
}
471+
absPath := aiderAbsPath(historyPath)
472+
ordinals := aiderEqualHeaderOrdinals(runs)
473+
for idx, run := range runs {
474+
if aiderRawID(absPath, run.rawHeader, ordinals[idx]) == rawID {
475+
return AiderVirtualPath(historyPath, idx), true
476+
}
477+
}
478+
return "", false
479+
}
480+
432481
// AiderRunMeta describes one run within a history file: its virtual
433482
// source path, positional index, and parsed start time. The sync engine
434483
// fans a physical file out into one session per meta. HasMessages reports
@@ -735,20 +784,8 @@ func FindAiderSourceFile(root, rawID string) string {
735784
return ""
736785
}
737786
for _, f := range DiscoverAiderSessions(root) {
738-
data, err := os.ReadFile(f.Path)
739-
if err != nil {
740-
continue
741-
}
742-
runs := splitAiderRuns(string(data))
743-
if len(runs) == 0 {
744-
continue
745-
}
746-
absPath := aiderAbsPath(f.Path)
747-
ordinals := aiderEqualHeaderOrdinals(runs)
748-
for idx, run := range runs {
749-
if aiderRawID(absPath, run.rawHeader, ordinals[idx]) == rawID {
750-
return AiderVirtualPath(f.Path, idx)
751-
}
787+
if path, ok := AiderVirtualPathForRawID(f.Path, rawID); ok {
788+
return path
752789
}
753790
}
754791
return ""

internal/ssh/resolve.go

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,52 @@ import (
1313
// is not a valid agent type, so parseResolvedDirs routes it separately.
1414
const resolveFilePrefix = "@file"
1515

16+
func aiderSkipDirCasePattern() string {
17+
return strings.Join(parser.AiderDiscoverySkipDirNames(), "|")
18+
}
19+
20+
func buildAiderResolveSnippet(envVar string) string {
21+
return fmt.Sprintf(
22+
"av_aider_walk() { "+
23+
"[ \"$av_aider_files\" -ge %d ] && return; "+
24+
"[ \"$av_aider_dirs\" -ge %d ] && return; "+
25+
"for av_entry in \"$1\"/* \"$1\"/.[!.]* \"$1\"/..?*; do "+
26+
"[ -e \"$av_entry\" ] || continue; "+
27+
"[ -L \"$av_entry\" ] && continue; "+
28+
"av_base=${av_entry##*/}; "+
29+
"if [ -d \"$av_entry\" ]; then "+
30+
"case \"$av_base\" in %s) continue;; esac; "+
31+
"[ \"$2\" -ge %d ] && continue; "+
32+
"av_aider_dirs=$((av_aider_dirs + 1)); "+
33+
"av_aider_walk \"$av_entry\" $(($2 + 1)); "+
34+
"[ \"$av_aider_files\" -ge %d ] && return; "+
35+
"[ \"$av_aider_dirs\" -ge %d ] && return; "+
36+
"elif [ -f \"$av_entry\" ] && [ \"$av_base\" = '%s' ]; then "+
37+
"echo \"%s:$av_entry\"; "+
38+
"av_aider_files=$((av_aider_files + 1)); "+
39+
"[ \"$av_aider_files\" -ge %d ] && return; "+
40+
"fi; "+
41+
"done; "+
42+
"}; "+
43+
"dir=\"${%s:-}\"; "+
44+
"case \"$dir\" in \"\"|\"$HOME\"|\"$HOME/\") ;; "+
45+
"*) if [ -d \"$dir\" ]; then "+
46+
"av_aider_files=0; av_aider_dirs=1; "+
47+
"av_aider_walk \"$dir\" 0; "+
48+
"fi;; esac\n",
49+
parser.AiderDiscoveryMaxFiles(),
50+
parser.AiderDiscoveryMaxDirs(),
51+
aiderSkipDirCasePattern(),
52+
parser.AiderDiscoveryMaxWalkDepth(),
53+
parser.AiderDiscoveryMaxFiles(),
54+
parser.AiderDiscoveryMaxDirs(),
55+
parser.AiderHistoryFileName(),
56+
string(parser.AgentAider),
57+
parser.AiderDiscoveryMaxFiles(),
58+
envVar,
59+
)
60+
}
61+
1662
// buildResolveScript generates a shell script that echoes each file-based
1763
// agent's resolved transfer target on the remote host. Output format:
1864
// "agentType:path\n" per agent target, plus "@file:path\n" lines for sibling
@@ -43,16 +89,7 @@ func buildResolveScript() string {
4389
// whole-home scan or tar.
4490
if def.Type == parser.AgentAider && rel == "" {
4591
if def.EnvVar != "" {
46-
fmt.Fprintf(&b,
47-
"dir=\"${%s:-}\"; "+
48-
"case \"$dir\" in \"\"|\"$HOME\"|\"$HOME/\") ;; "+
49-
"*) if [ -d \"$dir\" ]; then "+
50-
"find \"$dir\" -type f -name '%s' -print | sort | "+
51-
"while IFS= read -r f; do echo \"%s:$f\"; done; "+
52-
"fi;; esac\n",
53-
def.EnvVar, parser.AiderHistoryFileName(),
54-
string(def.Type),
55-
)
92+
b.WriteString(buildAiderResolveSnippet(def.EnvVar))
5693
}
5794
continue
5895
}

internal/ssh/resolve_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ func TestResolveScriptAiderScopedByEnvFindsHistoryFiles(t *testing.T) {
157157
require.NoError(t, os.WriteFile(
158158
filepath.Join(repoA, "source.go"), []byte("package main\n"), 0o644,
159159
))
160+
skippedDir := filepath.Join(codeRoot, "node_modules", "dep")
161+
require.NoError(t, os.MkdirAll(skippedDir, 0o755), "mkdir skipped dir")
162+
skippedHistory := filepath.Join(skippedDir, parser.AiderHistoryFileName())
163+
require.NoError(t, os.WriteFile(skippedHistory, []byte("# aider\n"), 0o644))
164+
deepDir := filepath.Join(codeRoot, "a", "b", "c", "d", "e")
165+
require.NoError(t, os.MkdirAll(deepDir, 0o755), "mkdir deep dir")
166+
deepHistory := filepath.Join(deepDir, parser.AiderHistoryFileName())
167+
require.NoError(t, os.WriteFile(deepHistory, []byte("# aider\n"), 0o644))
160168

161169
script := buildResolveScript()
162170
cmd := exec.Command("sh", "-c", script)
@@ -169,6 +177,10 @@ func TestResolveScriptAiderScopedByEnvFindsHistoryFiles(t *testing.T) {
169177
"explicit AIDER_DIR must resolve only aider history files")
170178
assert.NotContains(t, dirs[parser.AgentAider], codeRoot,
171179
"AIDER_DIR itself must not become a tar target")
180+
assert.NotContains(t, dirs[parser.AgentAider], skippedHistory,
181+
"remote aider discovery must prune local-discovery skip dirs")
182+
assert.NotContains(t, dirs[parser.AgentAider], deepHistory,
183+
"remote aider discovery must enforce the local depth cap")
172184
}
173185

174186
// TestResolveScriptAiderRejectsHomeOverride verifies that setting AIDER_DIR

0 commit comments

Comments
 (0)