Skip to content

Commit cd27e75

Browse files
committed
ssh: explicitly set ~ for home directory unless using cmd.exe
1 parent 2335240 commit cd27e75

File tree

6 files changed

+60
-13
lines changed

6 files changed

+60
-13
lines changed

pkg/agent/dial.go

+13-3
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,30 @@ func connect(logger *logging.Logger, transport Transport, mode, prompter string,
5050
// environment or cmd.exe), we can leave the "exe" suffix off the target
5151
// name. Fortunately this allows us to also avoid having to try the
5252
// combination of forward slashes + ".exe" for Windows POSIX environments.
53+
//
54+
// HACK: When invoking on cmd.exe, we leave off the ~ prefix, since cmd.exe
55+
// doesn't recognize it. In most cases the initial working directory for SSH
56+
// commands is the home directory, but when possible we try to be explicit,
57+
// to work around systems that use a different directory, such as Coder
58+
// workspaces, which allow different initial working directories to be
59+
// configured.
5360
pathSeparator := "/"
61+
pathComponents := []string{filesystem.HomeDirectorySpecial}
5462
if cmdExe {
5563
pathSeparator = "\\"
64+
pathComponents = nil
5665
}
5766
dataDirectoryName := filesystem.MutagenDataDirectoryName
5867
if mutagen.DevelopmentModeEnabled {
5968
dataDirectoryName = filesystem.MutagenDataDirectoryDevelopmentName
6069
}
61-
agentInvocationPath := strings.Join([]string{
70+
pathComponents = append(pathComponents,
6271
dataDirectoryName,
6372
filesystem.MutagenAgentsDirectoryName,
6473
mutagen.Version,
6574
BaseName,
66-
}, pathSeparator)
75+
)
76+
agentInvocationPath := strings.Join(pathComponents, pathSeparator)
6777

6878
// Compute the command to invoke.
6979
command := fmt.Sprintf("%s %s --%s=%s", agentInvocationPath, mode, FlagLogLevel, logger.Level())
@@ -204,7 +214,7 @@ func Dial(logger *logging.Logger, transport Transport, mode, prompter string) (i
204214
}
205215

206216
// Attempt to install.
207-
if err := install(logger, transport, prompter); err != nil {
217+
if err := install(logger, transport, prompter, cmdExe); err != nil {
208218
return nil, fmt.Errorf("unable to install agent: %w", err)
209219
}
210220

pkg/agent/install.go

+22-9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"runtime"
7+
"strings"
78

89
"github.com/google/uuid"
910

@@ -38,7 +39,7 @@ func Install() error {
3839

3940
// install attempts to probe an endpoint and install the appropriate agent
4041
// binary over the specified transport.
41-
func install(logger *logging.Logger, transport Transport, prompter string) error {
42+
func install(logger *logging.Logger, transport Transport, prompter string, cmdExe bool) error {
4243
// Detect the target platform.
4344
goos, goarch, posix, err := probe(transport, prompter)
4445
if err != nil {
@@ -68,14 +69,25 @@ func install(logger *logging.Logger, transport Transport, prompter string) error
6869
if err != nil {
6970
return fmt.Errorf("unable to generate UUID for agent copying: %w", err)
7071
}
71-
destination := BaseName + randomUUID.String()
72+
remoteFileName := BaseName + randomUUID.String()
7273
if goos == "windows" {
73-
destination += ".exe"
74+
remoteFileName += ".exe"
7475
}
7576
if posix {
76-
destination = "." + destination
77+
remoteFileName = "." + remoteFileName
7778
}
78-
if err = transport.Copy(agentExecutable, destination); err != nil {
79+
// HACK: On cmd.exe systems, the ~ special character is not understood to mean the home directory, so we leave it
80+
// off, and hope that the default copy directory is the home directory.
81+
pathSeparator := "/"
82+
pathComponents := []string{filesystem.HomeDirectorySpecial}
83+
if cmdExe {
84+
pathSeparator = "\\"
85+
pathComponents = nil
86+
}
87+
pathComponents = append(pathComponents, remoteFileName)
88+
fullRemotePath := strings.Join(pathComponents, pathSeparator)
89+
90+
if err = transport.Copy(agentExecutable, fullRemotePath); err != nil {
7991
return fmt.Errorf("unable to copy agent binary: %w", err)
8092
}
8193

@@ -89,7 +101,7 @@ func install(logger *logging.Logger, transport Transport, prompter string) error
89101
if err := prompting.Message(prompter, "Setting agent executability..."); err != nil {
90102
return fmt.Errorf("unable to message prompter: %w", err)
91103
}
92-
executabilityCommand := fmt.Sprintf("chmod +x %s", destination)
104+
executabilityCommand := fmt.Sprintf("chmod +x %s", fullRemotePath)
93105
if err := run(transport, executabilityCommand); err != nil {
94106
return fmt.Errorf("unable to set agent executability: %w", err)
95107
}
@@ -100,10 +112,11 @@ func install(logger *logging.Logger, transport Transport, prompter string) error
100112
return fmt.Errorf("unable to message prompter: %w", err)
101113
}
102114
var installCommand string
103-
if posix {
104-
installCommand = fmt.Sprintf("./%s %s", destination, CommandInstall)
115+
if posix && cmdExe {
116+
// TODO: is this path even possible?
117+
installCommand = fmt.Sprintf("./%s %s", fullRemotePath, CommandInstall)
105118
} else {
106-
installCommand = fmt.Sprintf("%s %s", destination, CommandInstall)
119+
installCommand = fmt.Sprintf("%s %s", fullRemotePath, CommandInstall)
107120
}
108121
if err := run(transport, installCommand); err != nil {
109122
return fmt.Errorf("unable to invoke agent installation: %w", err)

pkg/agent/transport/ssh/transport.go

+11
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os/exec"
99
"path/filepath"
1010
"strconv"
11+
"strings"
1112

1213
"github.com/mutagen-io/mutagen/pkg/agent"
1314
"github.com/mutagen-io/mutagen/pkg/agent/transport"
@@ -192,6 +193,12 @@ func (t *sshTransport) Command(command string) (*exec.Cmd, error) {
192193

193194
// ClassifyError implements the ClassifyError method of agent.Transport.
194195
func (t *sshTransport) ClassifyError(processState *os.ProcessState, errorOutput string) (bool, bool, error) {
196+
// Windows Powershell introduces line breaks in the errorOutput, which get
197+
// escaped before arriving at this function into a literal `\r` followed by
198+
// a newline. Strip these out so that line breaks don't screw with our
199+
// matching.
200+
errorOutput = strings.ReplaceAll(errorOutput, "\\r\n", "")
201+
195202
// SSH faithfully returns exit codes and error output, so we can use direct
196203
// methods for testing and classification. Note that we may get POSIX-like
197204
// error codes back even from Windows remotes, but that indicates a POSIX
@@ -228,6 +235,10 @@ func (t *sshTransport) ClassifyError(processState *os.ProcessState, errorOutput
228235
return false, true, nil
229236
} else if process.OutputIsWindowsCommandNotFound(errorOutput) {
230237
return true, true, nil
238+
} else if process.OutputIsWindowsPowershellCommandNotFound(errorOutput) {
239+
// It's Windows Powershell, not cmd.exe, so try (re)installing, but set cmdExe
240+
// to false.
241+
return true, false, nil
231242
}
232243

233244
// Just bail if we weren't able to determine the nature of the error.

pkg/filesystem/mutagen.go

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ const (
6666
// MutagenLicensingDirectoryName is the name of the licensing data directory
6767
// within the Mutagen data directory.
6868
MutagenLicensingDirectoryName = "licensing"
69+
70+
HomeDirectorySpecial = "~"
6971
)
7072

7173
// Mutagen computes (and optionally creates) subdirectories inside the Mutagen

pkg/process/errors.go

+11
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const (
2020
// windowsCommandNotFoundFragment is a fragment of the error output returned
2121
// on Windows systems when a command cannot be found.
2222
windowsCommandNotFoundFragment = "The system cannot find the path specified"
23+
// windowsPowershellCommandNotFoundFragment is a fragment of the error output
24+
// returned on Windows systems running Powershell when a command cannot be
25+
// found.
26+
windowsPowershellCommandNotFoundFragment = "is not recognized as the name of a cmdlet, function, script file, or operable program."
2327
)
2428

2529
// OutputIsPOSIXCommandNotFound returns whether or not a process' error output
@@ -38,6 +42,13 @@ func OutputIsWindowsInvalidCommand(output string) bool {
3842
// represents a command not found error on Windows.
3943
func OutputIsWindowsCommandNotFound(output string) bool {
4044
return strings.Contains(output, windowsCommandNotFoundFragment)
45+
46+
}
47+
48+
// OutputIsWindowsPowershellCommandNotFound returns whether or not a process' error
49+
// output represents a command not found error from Windows running Powershell.
50+
func OutputIsWindowsPowershellCommandNotFound(output string) bool {
51+
return strings.Contains(output, windowsPowershellCommandNotFoundFragment)
4152
}
4253

4354
// ExtractExitErrorMessage is a utility function that will attempt to extract

pkg/synchronization/controller.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ func newSession(
166166
)
167167
if err != nil {
168168
logger.Info("Beta connection failure:", err)
169-
return nil, fmt.Errorf("unable to connect to beta: %w", err)
169+
return nil, fmt.Errorf("unable to connect to beta_: %w", err)
170170
}
171171
}
172172

0 commit comments

Comments
 (0)