f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
374 lines
15 KiB
Go
374 lines
15 KiB
Go
package ssh
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
)
|
|
|
|
func TestBuildResolveScript(t *testing.T) {
|
|
script := buildResolveScript()
|
|
|
|
// Claude has CLAUDE_PROJECTS_DIR env var — must be referenced.
|
|
assert.Contains(t, script, "CLAUDE_PROJECTS_DIR")
|
|
assert.Contains(t, script, "CLAUDE_CONFIG_DIR")
|
|
|
|
// Only file-backed provider-authoritative agents belong in the resolver.
|
|
for _, def := range parser.Registry {
|
|
want := def.FileBased &&
|
|
parser.ProviderMigrationModes()[def.Type] ==
|
|
parser.ProviderMigrationProviderAuthoritative
|
|
if want {
|
|
assert.True(t, resolveScriptMentionsAgent(script, def.Type),
|
|
"file-backed provider-authoritative agent %s missing from script", def.Type)
|
|
continue
|
|
}
|
|
assert.False(t, resolveScriptMentionsAgent(script, def.Type),
|
|
"unsupported agent %s must stay out of the SSH resolver", def.Type)
|
|
}
|
|
}
|
|
|
|
func resolveScriptMentionsAgent(script string, agent parser.AgentType) bool {
|
|
name := string(agent)
|
|
return strings.Contains(script, "\""+name+":") ||
|
|
strings.Contains(script, " "+name+"\n")
|
|
}
|
|
|
|
func TestResolveScriptExcludesDevinProviderRoot(t *testing.T) {
|
|
home := t.TempDir()
|
|
devinRoot := filepath.Join(home, ".local", "share", "devin")
|
|
require.NoError(t, os.MkdirAll(devinRoot, 0o755))
|
|
|
|
out := runResolveScriptForTest(t, "HOME="+home, "DEVIN_DIR="+devinRoot)
|
|
|
|
dirs, _ := parseResolvedDirs(string(out))
|
|
assert.NotContains(t, dirs, parser.AgentDevin)
|
|
}
|
|
|
|
func TestResolveScriptHonorsClaudeConfigDirRoot(t *testing.T) {
|
|
home := t.TempDir()
|
|
root := filepath.Join(home, "claude personal")
|
|
projectsDir := filepath.Join(root, "projects")
|
|
require.NoError(t, os.MkdirAll(projectsDir, 0o755), "mkdir projects")
|
|
|
|
out := runResolveScriptForTest(t,
|
|
"HOME="+home,
|
|
"CLAUDE_CONFIG_DIR="+root,
|
|
)
|
|
|
|
dirs, _ := parseResolvedDirs(string(out))
|
|
assert.Contains(t, dirs[parser.AgentClaude], root+"/projects")
|
|
assert.NotContains(t, dirs[parser.AgentClaude], home+"/.claude/projects")
|
|
}
|
|
|
|
func TestResolveScriptTreatsEnvValuesAsData(t *testing.T) {
|
|
home := t.TempDir()
|
|
projectsDir := filepath.Join(home, "config root", "projects")
|
|
require.NoError(t, os.MkdirAll(projectsDir, 0o755), "mkdir projects")
|
|
|
|
script := buildResolveScript()
|
|
require.NotContains(t, script, "eval")
|
|
cmd := exec.Command("sh", "-c", script)
|
|
cmd.Env = []string{
|
|
"HOME=" + home,
|
|
"CLAUDE_PROJECTS_DIR=" + projectsDir,
|
|
}
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, "resolve script failed: output: %s", out)
|
|
|
|
dirs, _ := parseResolvedDirs(string(out))
|
|
assert.Contains(t, dirs[parser.AgentClaude], projectsDir)
|
|
}
|
|
|
|
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.
|
|
out := runResolveScriptForTest(t, "HOME=/nonexistent")
|
|
// No dirs should be found.
|
|
assert.Empty(t, strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
// TestResolveScriptIncludesCodexIndex verifies the resolve script emits the
|
|
// Codex session_index.jsonl as an extra file when it exists, so renamed
|
|
// titles get transferred and imported during remote SSH sync. Runs the real
|
|
// script through sh against a temp HOME rather than mocking it.
|
|
func TestResolveScriptIncludesCodexIndex(t *testing.T) {
|
|
home := t.TempDir()
|
|
sessionsDir := filepath.Join(home, ".codex", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessionsDir, 0o755), "mkdir sessions")
|
|
indexPath := filepath.Join(home, ".codex", "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, []byte("{}\n"), 0o644), "write index")
|
|
|
|
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.
|
|
// Match by POSIX suffix, which also guards against the parent
|
|
// expansion collapsing the index path to /session_index.jsonl.
|
|
dirs, extraFiles := parseResolvedDirs(string(out))
|
|
assert.Truef(t, hasSuffix(dirs[parser.AgentCodex], ".codex/sessions"),
|
|
"codex sessions dir should be resolved, got %v", dirs[parser.AgentCodex])
|
|
assert.Truef(t, hasSuffix(extraFiles, ".codex/session_index.jsonl"),
|
|
"codex session_index.jsonl should be an extra file, got %v", extraFiles)
|
|
}
|
|
|
|
// hasSuffix reports whether any element of paths ends with suffix.
|
|
func hasSuffix(paths []string, suffix string) bool {
|
|
for _, p := range paths {
|
|
if strings.HasSuffix(p, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestResolveScriptSkipsMissingCodexIndex verifies that a missing index
|
|
// produces no extra-file entry, so the transfer's tar command never names a
|
|
// nonexistent path (which would be a fatal, non-benign error).
|
|
func TestResolveScriptSkipsMissingCodexIndex(t *testing.T) {
|
|
home := t.TempDir()
|
|
require.NoError(t,
|
|
os.MkdirAll(filepath.Join(home, ".codex", "sessions"), 0o755),
|
|
"mkdir sessions")
|
|
|
|
out := runResolveScriptForTest(t, "HOME="+home)
|
|
|
|
_, extraFiles := parseResolvedDirs(string(out))
|
|
assert.Empty(t, extraFiles,
|
|
"no extra files when session_index.jsonl is absent")
|
|
}
|
|
|
|
// TestResolveScriptSkipsAiderHomeDefault verifies the resolve script does
|
|
// NOT infer a bare-$HOME Aider root. The remote resolver tars every emitted
|
|
// target, so Aider must stay opt-in even when a history file exists at home
|
|
// root. Runs the real script through sh against a temp HOME.
|
|
func TestResolveScriptSkipsAiderHomeDefault(t *testing.T) {
|
|
home := t.TempDir()
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(home, ".aider.chat.history.md"),
|
|
[]byte("# aider chat started at 2024-01-01 00:00:00\n"),
|
|
0o644,
|
|
), "write history")
|
|
|
|
out := runResolveScriptForTest(t, "HOME="+home)
|
|
|
|
dirs, _ := parseResolvedDirs(string(out))
|
|
assert.Empty(t, dirs[parser.AgentAider],
|
|
"aider bare-$HOME default must not be resolved for remote tar, got %v",
|
|
dirs[parser.AgentAider])
|
|
// Guard against $HOME ever appearing as a tar target via aider.
|
|
assert.NotContains(t, string(out), "aider:"+home,
|
|
"aider must not resolve to the whole home dir")
|
|
}
|
|
|
|
// TestResolveScriptAiderScopedByEnvFindsHistoryFiles verifies that an explicit
|
|
// AIDER_DIR discovers only aider history files for transfer. The remote sync
|
|
// treats resolved entries as tar targets, so emitting the code root would
|
|
// archive the entire repository instead of just .aider.chat.history.md files.
|
|
func TestResolveScriptAiderScopedByEnvFindsHistoryFiles(t *testing.T) {
|
|
home := t.TempDir()
|
|
codeRoot := filepath.Join(home, "code")
|
|
repoA := filepath.Join(codeRoot, "repo-a")
|
|
repoB := filepath.Join(codeRoot, "nested", "repo-b")
|
|
require.NoError(t, os.MkdirAll(repoA, 0o755), "mkdir repo A")
|
|
require.NoError(t, os.MkdirAll(repoB, 0o755), "mkdir repo B")
|
|
historyA := filepath.Join(repoA, parser.AiderHistoryFileName())
|
|
historyB := filepath.Join(repoB, parser.AiderHistoryFileName())
|
|
require.NoError(t, os.WriteFile(historyA, []byte("# aider\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(historyB, []byte("# aider\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(repoA, "source.go"), []byte("package main\n"), 0o644,
|
|
))
|
|
skippedDir := filepath.Join(codeRoot, "node_modules", "dep")
|
|
require.NoError(t, os.MkdirAll(skippedDir, 0o755), "mkdir skipped dir")
|
|
skippedHistory := filepath.Join(skippedDir, parser.AiderHistoryFileName())
|
|
require.NoError(t, os.WriteFile(skippedHistory, []byte("# aider\n"), 0o644))
|
|
deepDir := filepath.Join(codeRoot, "a", "b", "c", "d", "e")
|
|
require.NoError(t, os.MkdirAll(deepDir, 0o755), "mkdir deep dir")
|
|
deepHistory := filepath.Join(deepDir, parser.AiderHistoryFileName())
|
|
require.NoError(t, os.WriteFile(deepHistory, []byte("# aider\n"), 0o644))
|
|
|
|
out := runResolveScriptForTest(t, "HOME="+home, "AIDER_DIR="+codeRoot)
|
|
|
|
dirs, _ := parseResolvedDirs(string(out))
|
|
aiderTargets := slashPaths(dirs[parser.AgentAider])
|
|
assert.ElementsMatch(t, []string{filepath.ToSlash(historyA), filepath.ToSlash(historyB)}, aiderTargets,
|
|
"explicit AIDER_DIR must resolve only aider history files")
|
|
assert.NotContains(t, aiderTargets, filepath.ToSlash(codeRoot),
|
|
"AIDER_DIR itself must not become a tar target")
|
|
assert.NotContains(t, aiderTargets, filepath.ToSlash(skippedHistory),
|
|
"remote aider discovery must prune local-discovery skip dirs")
|
|
assert.NotContains(t, aiderTargets, filepath.ToSlash(deepHistory),
|
|
"remote aider discovery must enforce the local depth cap")
|
|
}
|
|
|
|
func TestResolveScriptAiderNewlinePathCannotInjectTarget(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows path APIs reject embedded newlines; this regression covers POSIX remote shell output")
|
|
}
|
|
home := t.TempDir()
|
|
codeRoot := filepath.Join(home, "code")
|
|
injected := "/home/victim/" + parser.AiderHistoryFileName()
|
|
maliciousDir := filepath.Join(codeRoot, "repo\naider:", "home", "victim")
|
|
require.NoError(t, os.MkdirAll(maliciousDir, 0o755), "mkdir malicious dir")
|
|
maliciousHistory := filepath.Join(maliciousDir, parser.AiderHistoryFileName())
|
|
require.NoError(t, os.WriteFile(maliciousHistory, []byte("# aider\n"), 0o644))
|
|
|
|
out := runResolveScriptForTest(t, "HOME="+home, "AIDER_DIR="+codeRoot)
|
|
|
|
dirs, _ := parseResolvedDirs(string(out))
|
|
assert.NotContains(t, dirs[parser.AgentAider], injected,
|
|
"newline-bearing repository paths must not inject a second transfer target")
|
|
for _, target := range dirs[parser.AgentAider] {
|
|
assert.NotContains(t, target, "\n",
|
|
"aider transfer target must not contain record separators")
|
|
}
|
|
}
|
|
|
|
func slashPaths(paths []string) []string {
|
|
out := make([]string, len(paths))
|
|
for i, p := range paths {
|
|
out[i] = filepath.ToSlash(p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestResolveScriptAiderRejectsHomeOverride verifies that setting AIDER_DIR
|
|
// to literal $HOME (the very thing the home-default skip prevents) is also
|
|
// dropped, so an unscoped override cannot reintroduce a whole-home tar.
|
|
func TestResolveScriptAiderRejectsHomeOverride(t *testing.T) {
|
|
home := t.TempDir()
|
|
|
|
for _, override := range []string{home, home + "/"} {
|
|
out := runResolveScriptForTest(t, "HOME="+home, "AIDER_DIR="+override)
|
|
|
|
dirs, _ := parseResolvedDirs(string(out))
|
|
assert.Empty(t, dirs[parser.AgentAider],
|
|
"AIDER_DIR=%q (== $HOME) must not resolve to a whole-home tar, got %v",
|
|
override, dirs[parser.AgentAider])
|
|
}
|
|
}
|
|
|
|
func TestResolveScriptWindsurfTargetsOnlySessionFiles(t *testing.T) {
|
|
home := t.TempDir()
|
|
userRoot := filepath.Join(home, "AppData", "Roaming", "Windsurf", "User")
|
|
workspaceRoot := filepath.Join(userRoot, "workspaceStorage")
|
|
workspaceDir := filepath.Join(workspaceRoot, "workspace-a")
|
|
stateDB := filepath.Join(workspaceDir, parser.WindsurfStateDBName)
|
|
stateWAL := stateDB + "-wal"
|
|
stateSHM := stateDB + "-shm"
|
|
workspaceJSON := filepath.Join(workspaceDir, "workspace.json")
|
|
secretPath := filepath.Join(workspaceDir, "extension-secret.json")
|
|
require.NoError(t, os.MkdirAll(workspaceDir, 0o755))
|
|
require.NoError(t, os.WriteFile(stateDB, []byte("state"), 0o644))
|
|
require.NoError(t, os.WriteFile(stateWAL, []byte("wal"), 0o644))
|
|
require.NoError(t, os.WriteFile(stateSHM, []byte("shm"), 0o644))
|
|
require.NoError(t, os.WriteFile(workspaceJSON, []byte("{}\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(secretPath, []byte("secret"), 0o644))
|
|
|
|
out := runResolveScriptForTest(t, "HOME="+home)
|
|
|
|
records := resolveOutputRecords(string(out))
|
|
userRootSuffix := filepath.ToSlash(filepath.Join("AppData", "Roaming", "Windsurf", "User"))
|
|
workspaceRootSuffix := filepath.ToSlash(filepath.Join(userRootSuffix, "workspaceStorage"))
|
|
workspaceSuffix := filepath.ToSlash(filepath.Join(workspaceRootSuffix, "workspace-a"))
|
|
agentFilePrefix := resolveAgentFilePrefix + ":" + string(parser.AgentWindsurf)
|
|
assert.True(t, hasRecordWithPathSuffix(records, string(parser.AgentWindsurf), userRootSuffix))
|
|
assert.True(t, hasRecordWithPathSuffix(records, agentFilePrefix,
|
|
filepath.ToSlash(filepath.Join(workspaceSuffix, parser.WindsurfStateDBName))))
|
|
assert.True(t, hasRecordWithPathSuffix(records, agentFilePrefix,
|
|
filepath.ToSlash(filepath.Join(workspaceSuffix, parser.WindsurfStateDBName+"-wal"))))
|
|
assert.False(t, hasRecordWithPathSuffix(records, agentFilePrefix,
|
|
filepath.ToSlash(filepath.Join(workspaceSuffix, parser.WindsurfStateDBName+"-shm"))))
|
|
assert.True(t, hasRecordWithPathSuffix(records, agentFilePrefix,
|
|
filepath.ToSlash(filepath.Join(workspaceSuffix, "workspace.json"))))
|
|
assert.False(t, hasRecordWithPathSuffix(records, string(parser.AgentWindsurf), workspaceRootSuffix))
|
|
assert.False(t, hasRecordWithPathSuffix(records, agentFilePrefix,
|
|
filepath.ToSlash(filepath.Join(workspaceSuffix, filepath.Base(secretPath)))))
|
|
}
|
|
|
|
func hasRecordWithPathSuffix(records []string, prefix, suffix string) bool {
|
|
for _, record := range records {
|
|
if strings.HasPrefix(record, prefix+":") && strings.HasSuffix(record, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func runResolveScriptForTest(t *testing.T, env ...string) []byte {
|
|
t.Helper()
|
|
cmd := exec.Command("sh")
|
|
cmd.Stdin = strings.NewReader(buildResolveScript())
|
|
cmd.Env = env
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, "resolve script failed: output: %s", out)
|
|
return out
|
|
}
|
|
|
|
func TestParseResolvedDirs(t *testing.T) {
|
|
input := "claude:/home/wes/.claude/projects\n" +
|
|
"codex:/home/wes/.codex/sessions\n" +
|
|
"codex:\n" +
|
|
"copilot:/home/wes/.copilot\n" +
|
|
"@file:/home/wes/.codex/session_index.jsonl\n" +
|
|
"@file:/home/wes/.codex/session_index.jsonl\n" +
|
|
"\n"
|
|
|
|
dirs, extraFiles := parseResolvedDirs(input)
|
|
|
|
// codex has one valid dir and one empty (excluded) entry.
|
|
assert.Equal(t, []string{"/home/wes/.codex/sessions"}, dirs[parser.AgentCodex])
|
|
|
|
// claude and copilot present.
|
|
assert.Equal(t, []string{"/home/wes/.claude/projects"}, dirs[parser.AgentClaude])
|
|
assert.Equal(t, []string{"/home/wes/.copilot"}, dirs[parser.AgentCopilot])
|
|
|
|
assert.Len(t, dirs, 3)
|
|
|
|
// The duplicate index file line is deduplicated.
|
|
assert.Equal(t,
|
|
[]string{"/home/wes/.codex/session_index.jsonl"}, extraFiles)
|
|
}
|
|
|
|
func TestParseResolvedDirsNULRecords(t *testing.T) {
|
|
input := "claude:/home/wes/.claude/projects\x00" +
|
|
"aider:/home/wes/code/repo/.aider.chat.history.md\x00" +
|
|
"@file:/home/wes/.codex/session_index.jsonl\x00"
|
|
|
|
dirs, extraFiles := parseResolvedDirs(input)
|
|
|
|
assert.Equal(t, []string{"/home/wes/.claude/projects"}, dirs[parser.AgentClaude])
|
|
assert.Equal(t,
|
|
[]string{"/home/wes/code/repo/.aider.chat.history.md"},
|
|
dirs[parser.AgentAider])
|
|
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)
|
|
}
|