Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

300 lines
8.2 KiB
Go

package ssh
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"sync/atomic"
"time"
"go.kenn.io/agentsview/internal/parser"
"go.kenn.io/agentsview/internal/remotesync"
)
// 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, 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,
extraFiles []string,
) string {
var paths []string
for agent, agentDirs := range dirs {
if _, fileScoped := files[agent]; fileScoped {
continue
}
for _, d := range agentDirs {
if path := tarListPath(d); path != "" {
paths = append(paths, shellQuote(path))
}
}
}
for _, agentFiles := range files {
for _, f := range agentFiles {
if path := tarListPath(f); path != "" {
paths = append(paths, shellQuote(path))
}
}
}
for _, f := range extraFiles {
if path := tarListPath(f); path != "" {
paths = append(paths, shellQuote(path))
}
}
var b strings.Builder
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("av_emit_tar_path ")
b.WriteString(path)
b.WriteByte('\n')
}
b.WriteString("} | tar cf - -C / -T -\n")
return b.String()
}
func tarListPath(path string) string {
if strings.ContainsAny(path, "\x00\n\r") {
return ""
}
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 {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
// downloadAndExtract tars remote agent dirs and extracts to a local
// temp dir. Returns the temp dir path; caller must clean up.
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, files, extraFiles)
stdout, cleanup, err := runSSHScriptStream(
ctx, host, user, port, sshOpts, tarCmd,
)
if err != nil {
return "", fmt.Errorf("download: %w", err)
}
tmpDir, err := os.MkdirTemp("", "agentsview-ssh-*")
if err != nil {
stdout.Close()
_ = cleanup()
return "", fmt.Errorf("create temp dir: %w", err)
}
// Wrap stdout with a progress counter so the user
// can see data flowing during the transfer.
pr := &progressReader{r: stdout}
done := make(chan struct{})
go pr.printLoop(done)
skipped, extractErr := remotesync.ExtractTarStream(ctx, pr, tmpDir)
close(done)
pr.printFinal()
if extractErr != nil {
stdout.Close()
os.RemoveAll(tmpDir)
_ = cleanup()
return "", fmt.Errorf("extract tar: %w", extractErr)
}
if skipped > 0 {
fmt.Printf(
" Skipped %d self-referential hardlink(s).\n",
skipped,
)
}
// stdout is consumed by the extractor; close it so the SSH
// process can exit cleanly. A non-zero remote tar exit is
// fatal unless its stderr shows only benign warnings (files
// changing or vanishing as the remote read them).
stdout.Close()
if err := cleanup(); err != nil {
if !remoteTarStderrBenign(err) {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("ssh tar: %w", err)
}
fmt.Printf(
" Remote tar reported benign warnings; continuing.\n",
)
}
return tmpDir, nil
}
// remapToRemotePath converts a temp-dir path back to the original
// remote path. Strips the temp dir prefix so the remainder is the
// absolute path as it existed on the remote host.
//
// Example:
//
// tempDir="/tmp/sync-123"
// localPath="/tmp/sync-123/home/wes/.claude/foo.jsonl"
// result="/home/wes/.claude/foo.jsonl"
func remapToRemotePath(tempDir, remoteDir, localPath string) string {
return remotesync.RemapToRemotePath(tempDir, remoteDir, localPath)
}
// progressReader wraps a reader and tracks bytes read.
type progressReader struct {
r io.Reader
bytes atomic.Int64
}
func (pr *progressReader) Read(p []byte) (int, error) {
n, err := pr.r.Read(p)
pr.bytes.Add(int64(n))
return n, err
}
func (pr *progressReader) printLoop(done <-chan struct{}) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
fmt.Printf(
"\r Received %s...",
formatBytes(pr.bytes.Load()),
)
}
}
}
func (pr *progressReader) printFinal() {
fmt.Printf(
"\r Received %s \n",
formatBytes(pr.bytes.Load()),
)
}
func formatBytes(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.1f GB", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.1f MB", float64(b)/(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.1f KB", float64(b)/(1<<10))
default:
return fmt.Sprintf("%d bytes", b)
}
}
// remappedDir returns the temp-dir equivalent of a remote dir.
//
// Example:
//
// tempDir="/tmp/sync-123"
// remoteDir="/home/wes/.claude"
// result="/tmp/sync-123/home/wes/.claude"
func remappedDir(tempDir, remoteDir string) string {
return remotesync.RemappedDir(tempDir, remoteDir)
}
// benignRemoteTarPrimary are remote tar (creation-side) stderr
// messages we treat as non-fatal: a file mutated or vanished while it
// was being archived. The resulting archive is still well-formed, and
// the local extractor independently validates its integrity. Stored
// lowercase; matched case-insensitively against a lowercased line.
var benignRemoteTarPrimary = []string{
"file changed as we read it",
"file removed before we read it",
}
// benignRemoteTarFallout are the summary lines tar prints after a
// non-zero exit. They are tolerated only alongside a primary benign
// warning, never on their own. Stored lowercase (see above).
var benignRemoteTarFallout = []string{
"exiting with failure status due to previous errors", // GNU tar
"error exit delayed from previous errors", // bsdtar
}
// remoteTarStderrBenign reports whether a non-nil cleanup() error from
// the remote tar stream is safe to ignore. It is fail-closed: it
// returns true only for a *commandError whose every stderr line is a
// known-benign warning and which includes at least one primary
// warning. Truncation, corrupt archives, permission errors, and
// SSH-level failures are never benign, so they can never be persisted
// to the skip cache as a successful sync.
func remoteTarStderrBenign(err error) bool {
var ce *commandError
if !errors.As(err, &ce) {
return false
}
sawPrimary := false
for line := range strings.SplitSeq(ce.Stderr, "\n") {
// Lowercase for case-insensitive matching: GNU tar is
// inconsistent about capitalization (create.c emits
// "File removed before we read it" with a capital F but
// "file changed as we read it" lowercase).
line = strings.ToLower(
strings.TrimRight(strings.TrimSpace(line), ". "),
)
switch {
case line == "":
continue
case hasBenignPrimary(line):
sawPrimary = true
case hasBenignFallout(line):
// Summary line: tolerated only as attached fallout.
default:
return false
}
}
return sawPrimary
}
// hasBenignPrimary reports whether line is a per-file remote tar
// warning about a file mutating or vanishing mid-archive. tar formats
// these as "<path>: <message>", so the phrase is matched as a suffix
// after the ": " separator. Matching it anywhere in the line would let
// a benign phrase embedded in a file path mask a real error reported
// for that same path (e.g. ".../file changed as we read it: Cannot
// open: Permission denied").
func hasBenignPrimary(line string) bool {
for _, phrase := range benignRemoteTarPrimary {
if strings.HasSuffix(line, ": "+phrase) {
return true
}
}
return false
}
// hasBenignFallout reports whether line is a tar end-of-run summary,
// which tar prints with no leading path.
func hasBenignFallout(line string) bool {
for _, phrase := range benignRemoteTarFallout {
if strings.HasSuffix(line, phrase) {
return true
}
}
return false
}