fix(checkpoint): hint ssh-agent on BatchMode auth failure · Entire

fix(checkpoint): hint ssh-agent on BatchMode auth failure

e3e5b7b→main·

Pre-push checkpoint pushes already fail fast under BatchMode=yes; when the failure is SSH auth-shaped, print an actionable ssh-agent hint instead of only a Permission denied warning. Document the touch-only vs PIN-protected security-key tradeoff and the BatchMode=no escape hatch.

Co-authored-by: Cursor cursoragent@cursor.com

Changes

4

45 unmodified lines

// user's own `git push` until the checkpoint push budget kills it, with no way
// to type the passphrase. Foreground commands (resume, explain) leave it unset
// so they can still prompt.

// BatchMode tradeoffs (issue #1523):
//   - Passphrase-protected keys with no ssh-agent: fail fast (desired).
//   - Touch-only security keys (sk-, user-presence only): still work — touch is
//     not a terminal passphrase read.
//   - PIN-protected FIDO2 keys (verify-required): PIN entry goes through ssh's
//     passphrase reader, so BatchMode suppresses it and the push fails. Load the
//     key into ssh-agent beforehand, or set an explicit BatchMode=no via
//     GIT_SSH_COMMAND / core.sshCommand (respected; we do not override it).
func WithNonInteractiveSSH(ctx context.Context) context.Context {
    return context.WithValue(ctx, nonInteractiveSSHKey{}, true)
}

// IsNonInteractiveSSH reports whether ctx was marked with WithNonInteractiveSSH.
func IsNonInteractiveSSH(ctx context.Context) bool {
    return nonInteractiveSSHFromContext(ctx)
}

func nonInteractiveSSHFromContext(ctx context.Context) bool {
    v, ok := ctx.Value(nonInteractiveSSHKey{}).(bool)
    return ok && v
}

// LooksLikeSSHAuthFailure reports whether errText looks like an SSH
// authentication failure (passphrase/PIN unavailable under BatchMode, missing
// agent identity, publickey rejection, etc.). Used to print an actionable
// ssh-agent hint from the pre-push checkpoint path.
func LooksLikeSSHAuthFailure(errText string) bool {
    if errText == "" {
        return false
    }
    lower := strings.ToLower(errText)
    needles := []string{
        "permission denied (publickey)",
        "permission denied (keyboard-interactive",
        "permission denied (password)",
        "too many authentication failures",
        "no more authentication methods to try",
        "could not read from remote repository",
        "enter passphrase for key", // should not appear under BatchMode, but keep
        "error reading ssh protocol banner", // sometimes accompanies aborted auth
    }
    for _, n := range needles {
        if strings.Contains(lower, n) {
            return true
        }
    }
    // Generic publickey denial without the parenthetical form.
    if strings.Contains(lower, "permission denied") && strings.Contains(lower, "publickey") {
        return true
    }
    return false
}

// batchModeOptionRe matches an explicit BatchMode ssh option (e.g.
// "-o BatchMode=yes" or "BatchMode=no"), case-insensitively. Anchored with \b
// so it doesn't false-positive on unrelated text that merely contains
1243 unmodified lines

assert.Nil(t, cmd.Env, "unmarked command should not set a custom env")
})

func TestLooksLikeSSHAuthFailure(t *testing.T) {
    t.Parallel()
    cases := []struct {
        in   string
        want bool
    }{
        {"git push: Permission denied (publickey).", true},
        {"Permission denied (publickey,password).", true},
        {"ERROR: Permission denied (publickey).
\nfatal: Could not read from remote repository.", true},
        {"fatal: Could not read from remote repository.", true},
        {"non-fast-forward", false},
        {"Connection timed out", false},
        {"", false},
    }
    for _, tt := range cases {
        t.Run(tt.in, func(t *testing.T) {
            t.Parallel()
            assert.Equal(t, tt.want, LooksLikeSSHAuthFailure(tt.in))
        })
    }
}

func TestIsNonInteractiveSSH(t *testing.T) {
    t.Parallel()
    assert.False(t, IsNonInteractiveSSH(context.Background()))
    assert.True(t, IsNonInteractiveSSH(WithNonInteractiveSSH(context.Background())))
}
Mcmd/entire/cli/checkpoint/remote/git.go+45
177 unmodified lines

// Non-interactive SSH (pre-push BatchMode): auth failures cannot be fixed by
    // fetch+rebase, and retrying would just reprint the same opaque error.
    // Surface an actionable ssh-agent hint and skip recovery (issue #1523).
    if remote.IsNonInteractiveSSH(ctx) && remote.LooksLikeSSHAuthFailure(err.Error()) {
        fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't push %s: %v\n", refLabel, err)
        printNonInteractiveSSHAuthHint()
        printCheckpointRemoteHint(target)
        return nil
    }

// Push failed - likely non-fast-forward. Try to fetch and rebase.
    // Spanned (with the network fetch as a child) so the trace distinguishes
    // "the raw push is slow" from "we keep hitting contention and re-syncing".
    if syncErr != nil {
        stop("")
        fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync %s: %v\n", refLabel, syncErr)
        if remote.IsNonInteractiveSSH(ctx) && remote.LooksLikeSSHAuthFailure(syncErr.Error()) {
            printNonInteractiveSSHAuthHint()
        }
        printCheckpointRemoteHint(target)
        return nil // Don't fail the main push
    }
3 unmodified lines

// Reset the once for this test process isolation: reassign the sync.Once.
    sshAuthHintOnce = sync.Once{}

var buf bytes.Buffer
    old := os.Stderr
    r, w, err := os.Pipe()
    require.NoError(t, err)
    os.Stderr = w
    printNonInteractiveSSHAuthHint()
    printNonInteractiveSSHAuthHint() // second call must be a no-op
    require.NoError(t, w.Close())
    os.Stderr = old
    _, copyErr := io.Copy(&buf, r)
    require.NoError(t, copyErr)
    out := buf.String()
    assert.Contains(t, out, "ssh-add")
    assert.Contains(t, out, "Checkpoint push skipped")
    assert.Equal(t, 1, strings.Count(out, "Checkpoint push skipped"), "hint must print once")
}
Mcmd/entire/cli/strategy/push\_common.go+30
3 unmodified lines