Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

335 lines
11 KiB
Go

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
}