package ssh import ( "context" "fmt" "path" "strings" "go.kenn.io/agentsview/internal/parser" ) // resolveFilePrefix marks lines in the resolve script output that name // an extra file (not an agent directory) to include in the transfer. It // 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 { return strings.Join(parser.AiderDiscoverySkipDirNames(), "|") } func buildAiderResolveSnippet(envVar string) string { return fmt.Sprintf( "av_aider_walk() { "+ "[ \"$av_aider_files\" -ge %d ] && return; "+ "[ \"$av_aider_dirs\" -ge %d ] && return; "+ "for av_entry in \"$1\"/* \"$1\"/.[!.]* \"$1\"/..?*; do "+ "[ -e \"$av_entry\" ] || continue; "+ "[ -L \"$av_entry\" ] && continue; "+ "av_base=${av_entry##*/}; "+ "if [ -d \"$av_entry\" ]; then "+ "case \"$av_base\" in %s) continue;; esac; "+ "[ \"$2\" -ge %d ] && continue; "+ "av_aider_dirs=$((av_aider_dirs + 1)); "+ "av_aider_walk \"$av_entry\" $(($2 + 1)); "+ "[ \"$av_aider_files\" -ge %d ] && return; "+ "[ \"$av_aider_dirs\" -ge %d ] && return; "+ "elif [ -f \"$av_entry\" ] && [ \"$av_base\" = '%s' ]; then "+ "printf '%%s\\000' \"%s:$av_entry\"; "+ "av_aider_files=$((av_aider_files + 1)); "+ "[ \"$av_aider_files\" -ge %d ] && return; "+ "fi; "+ "done; "+ "}; "+ "dir=\"${%s:-}\"; "+ "case \"$dir\" in \"\"|\"$HOME\"|\"$HOME/\") ;; "+ "*) if [ -d \"$dir\" ]; then "+ "av_aider_files=0; av_aider_dirs=1; "+ "av_aider_walk \"$dir\" 0; "+ "fi;; esac\n", parser.AiderDiscoveryMaxFiles(), parser.AiderDiscoveryMaxDirs(), aiderSkipDirCasePattern(), parser.AiderDiscoveryMaxWalkDepth(), parser.AiderDiscoveryMaxFiles(), parser.AiderDiscoveryMaxDirs(), parser.AiderHistoryFileName(), string(parser.AgentAider), parser.AiderDiscoveryMaxFiles(), envVar, ) } // buildResolveScript generates a shell script that echoes each file-based // agent's resolved transfer target on the remote host. Output format: // "agentType:path\n" per agent target, plus "@file:path\n" lines for sibling // metadata files such as Codex's session_index.jsonl. // // Only includes file-backed agents whose local sources are resolved via their // provider facade. For each agent with an EnvVar, the script checks the env var // first and falls back to the default dir. Dirs (and files) that don't exist on // the remote are skipped. func buildResolveScript() string { var b strings.Builder b.WriteString( "av_emit_agent_file() { " + "agent=\"$1\"; " + "file=\"$2\"; " + "[ -f \"$file\" ] && printf '%s\\000' \"" + resolveAgentFilePrefix + ":$agent:$file\"; " + "}\n" + "av_emit_windsurf_target() { " + "target=\"$1\"; " + "case \"$target\" in */) target=\"${target%/}\";; esac; " + "workspace=\"$target\"; " + "case \"$workspace\" in */workspaceStorage) ;; " + "*) workspace=\"$workspace/workspaceStorage\";; esac; " + "[ -d \"$workspace\" ] || return; " + "av_windsurf_root_emitted=0; " + "for av_windsurf_ws in \"$workspace\"/*; do " + "[ -d \"$av_windsurf_ws\" ] || continue; " + "av_windsurf_db=\"$av_windsurf_ws/" + parser.WindsurfStateDBName + "\"; " + "[ -f \"$av_windsurf_db\" ] || continue; " + "if [ \"$av_windsurf_root_emitted\" -eq 0 ]; then " + "printf '%s\\000' \"" + string(parser.AgentWindsurf) + ":$target\"; " + "av_windsurf_root_emitted=1; " + "fi; " + "for av_windsurf_file in \"$av_windsurf_db\" \"$av_windsurf_db-wal\" \"$av_windsurf_ws/workspace.json\"; do " + "av_emit_agent_file \"" + string(parser.AgentWindsurf) + "\" \"$av_windsurf_file\"; " + "done; " + "done; " + "}\n" + "av_emit_target() { " + "agent=\"$1\"; " + "target=\"$2\"; " + "if [ \"$agent\" = \"" + string(parser.AgentWindsurf) + "\" ]; then " + "av_emit_windsurf_target \"$target\"; " + "return; " + "fi; " + "[ -d \"$target\" ] && printf '%s\\000' \"$agent:$target\"; " + "}\n" + "av_emit_dir() { " + "dir=\"$1\"; " + "[ -n \"$dir\" ] || dir=\"$2\"; " + "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\"; " + "av_emit_target \"$5\" \"$dir\"; " + "}\n" + "av_emit_codex_index() { " + "idx=\"${dir%/*}/" + parser.CodexSessionIndexFilename + "\"; " + "[ -f \"$idx\" ] && printf '%s\\000' \"" + resolveFilePrefix + ":$idx\"; " + "}\n", ) for _, def := range parser.Registry { if !resolveAgentHasOnDiskSource(def) { continue } // Aider has no central store and no safe default root: it writes // one .aider.chat.history.md per repository, so after the opt-in // change it carries no DefaultDirs and the DefaultDirs loop below // never runs for it. Handle it independently so an explicitly // configured remote AIDER_DIR still resolves history files. Remote // sync emits only discovered .aider.chat.history.md files as tar // targets, never the configured code root or the remote $HOME. The // shell guard in buildAiderResolveSnippet also drops AIDER_DIR set // to literal "$HOME" (or "$HOME/"), so an unscoped override cannot // reintroduce a whole-home scan or tar. Local sync is unaffected: // it discovers via its provider facade, not this script. if def.Type == parser.AgentAider { if def.EnvVar != "" { b.WriteString(buildAiderResolveSnippet(def.EnvVar)) } continue } for _, rel := range def.DefaultDirs { defaultDir := "$HOME/" + rel if def.DefaultRootEnvVar != "" { rootTail := remoteDefaultRootTail(rel) rootSuffix := "" if rootTail != "" { rootSuffix = "/" + rootTail } fmt.Fprintf(&b, "av_emit_rooted_dir \"%s\" \"%s\" \"%s\" \"%s\" %s\n", remoteEnvExpansion(def.EnvVar), remoteEnvExpansion(def.DefaultRootEnvVar), rootSuffix, defaultDir, string(def.Type), ) } else { fmt.Fprintf(&b, "av_emit_dir \"%s\" \"%s\" %s\n", remoteEnvExpansion(def.EnvVar), defaultDir, string(def.Type), ) } // Codex stores renameable session titles in // session_index.jsonl, which sits beside (not inside) // sessions/ and archived_sessions/. Emit it so renames // import on remote hosts too. ${dir%/*} is the parent. if def.Type == parser.AgentCodex { b.WriteString("av_emit_codex_index\n") } } } // Ensure exit 0 — the last [ -d ]/[ -f ] test may fail if that // path doesn't exist, which would make sh exit non-zero. b.WriteString("true\n") return b.String() } func remoteEnvExpansion(envVar string) string { if envVar == "" { return "" } return "${" + envVar + ":-}" } // BuildResolveScriptForTest exposes the SSH resolver script to // internal/remotesync parity tests. func BuildResolveScriptForTest() string { return buildResolveScript() } func remoteDefaultRootTail(rel string) string { cleaned := path.Clean(rel) if _, tail, ok := strings.Cut(cleaned, "/"); ok && tail != "" { return tail } return "" } // resolveAgentHasOnDiskSource reports whether a file-backed agent has local // sources the resolve script should probe via the provider facade. func resolveAgentHasOnDiskSource(def parser.AgentDef) bool { if !def.FileBased { return false } switch parser.ProviderMigrationModes()[def.Type] { case parser.ProviderMigrationProviderAuthoritative: _, ok := parser.ProviderFactoryByType(def.Type) return ok default: return false } } // 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, 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 == "" { continue } key, value, ok := strings.Cut(record, ":") if !ok || invalidResolvedPath(value) { continue } if key == resolveFilePrefix { if _, dup := seenFile[value]; dup { continue } seenFile[value] = struct{}{} 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() { continue } 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 } // ParseResolvedTargetsForTest exposes SSH resolver output parsing to // internal/remotesync parity tests. func ParseResolvedTargetsForTest(output string) (map[parser.AgentType][]string, []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) } return strings.Split(output, "\n") } func invalidResolvedPath(value string) bool { return value == "" || strings.ContainsAny(value, "\x00\r\n") } // resolveDirs runs the resolve script on the remote host via SSH and // returns the discovered agent directories plus extra sibling files // (such as Codex's session_index.jsonl) to include in the transfer. func resolveDirs( ctx context.Context, host, user string, port int, sshOpts []string, ) (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, nil, fmt.Errorf("resolve dirs: %w", err) } dirs, files, extraFiles := parseResolvedTargets(string(out)) return dirs, files, extraFiles, nil }