Skip to content

Commit ceccec7

Browse files
authored
fix(ssh): write Windows ProxyCommand paths with forward slashes (#1157)
open-remote-ssh v0.4.0 (and 1.16.4) treats `\` in a ProxyCommand as an escape on Windows, so paths reached the CLI without separators and the connection failed. Write Windows drive-letter paths with `/` for the binary and the config, network-info, log, token and URL paths. UNC and `\\?\` paths are unchanged. Workaround until open-remote-ssh 0.4.1 ships the upstream fix (jeanp413/open-remote-ssh#342); `/` keeps working after that.
1 parent dae2fd9 commit ceccec7

6 files changed

Lines changed: 62 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
a backtick, `\`, `%` or a line break.
2424
- On Windows, a `coder.headerCommand` containing `"` was split into several
2525
arguments before it reached the CLI.
26+
- On Windows, the open source Remote - SSH extension 0.4.0 dropped every `\`
27+
from the paths in the SSH `ProxyCommand`. Drive-letter paths are now written
28+
with `/`.
2629

2730
## [v1.17.0](https://github.com/coder/vscode-coder/releases/tag/v1.17.0) 2026-10-13
2831

‎src/common/strings.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,15 @@ export function escapeShellArg(arg: string): string {
5757
export function escapeProxyCommandArg(arg: string): string {
5858
return /[$`\\%\r\n]/.test(arg) ? escapeShellArg(arg) : escapeCommandArg(arg);
5959
}
60+
61+
/**
62+
* {@link escapeProxyCommandArg} for a local path. Windows drive paths use `/`,
63+
* which every runner accepts; open-remote-ssh 0.4.0 strips `\` as an escape.
64+
*/
65+
export function escapeProxyCommandPath(filePath: string): string {
66+
const portable =
67+
os.platform() === "win32" && /^[a-z]:\\/i.test(filePath)
68+
? filePath.replaceAll("\\", "/")
69+
: filePath;
70+
return escapeProxyCommandArg(portable);
71+
}

‎src/remote/remote.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { HttpStatusCode } from "../api/httpStatusCode";
1818
import { extractAgents } from "../api/models";
1919
import { version as cliVersion } from "../cli/cliExec";
2020
import { expandPath } from "../common/fs";
21-
import { escapeCommandArg } from "../common/strings";
21+
import { escapeCommandArg, escapeProxyCommandPath } from "../common/strings";
2222
import {
2323
CONFIG_CHANGE_DEBOUNCE_MS,
2424
watchConfigurationChanges,
@@ -865,7 +865,7 @@ export class Remote {
865865
}
866866
await fs.mkdir(logDir, { recursive: true });
867867
this.logger.info("SSH proxy diagnostics are being written to", logDir);
868-
return ["--log-dir", escapeCommandArg(logDir), "-v"];
868+
return ["--log-dir", escapeProxyCommandPath(logDir), "-v"];
869869
}
870870

871871
private getMainSshConfigPath(): string {
@@ -1114,7 +1114,7 @@ function coderCommand(
11141114
vscode.workspace.getConfiguration(),
11151115
options.cliAuth,
11161116
);
1117-
return `${escapeCommandArg(options.binaryPath)} ${globalFlags.join(" ")} ${subcommand} ${flags.join(" ")}`;
1117+
return `${escapeProxyCommandPath(options.binaryPath)} ${globalFlags.join(" ")} ${subcommand} ${flags.join(" ")}`;
11181118
}
11191119

11201120
/** ProxyCommand for CLIs that support wildcard hosts. */
@@ -1126,7 +1126,7 @@ export function buildSshProxyCommand(
11261126
"--stdio",
11271127
`--usage-app=${escapeCommandArg(options.sessionApp)}`,
11281128
"--network-info-dir",
1129-
escapeCommandArg(options.pathResolver.getNetworkInfoPath()),
1129+
escapeProxyCommandPath(options.pathResolver.getNetworkInfoPath()),
11301130
...options.logArgs,
11311131
"--ssh-host-prefix",
11321132
options.hostPrefix,
@@ -1145,12 +1145,12 @@ function buildVscodeSshProxyCommand(
11451145
const { pathResolver, label } = options;
11461146
return coderCommand(options, "vscodessh", [
11471147
"--network-info-dir",
1148-
escapeCommandArg(pathResolver.getNetworkInfoPath()),
1148+
escapeProxyCommandPath(pathResolver.getNetworkInfoPath()),
11491149
...options.logArgs,
11501150
"--session-token-file",
1151-
escapeCommandArg(pathResolver.getSessionTokenPath(label)),
1151+
escapeProxyCommandPath(pathResolver.getSessionTokenPath(label)),
11521152
"--url-file",
1153-
escapeCommandArg(pathResolver.getUrlPath(label)),
1153+
escapeProxyCommandPath(pathResolver.getUrlPath(label)),
11541154
"%h",
11551155
]);
11561156
}

‎src/settings/cli.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import os from "node:os";
22

33
import { expandPath } from "../common/fs";
4-
import { escapeCommandArg, escapeProxyCommandArg } from "../common/strings";
4+
import {
5+
escapeCommandArg,
6+
escapeProxyCommandArg,
7+
escapeProxyCommandPath,
8+
} from "../common/strings";
59
import { validateDeploymentUrl } from "../common/validation";
610

711
import { getHeaderArgs } from "./headers";
@@ -52,7 +56,13 @@ export function getGlobalShellFlags(
5256
validateDeploymentUrl(auth.url);
5357
return [
5458
escapeCommandArg("--token="),
55-
...buildGlobalFlags(configs, auth, escapeCommandArg, escapeProxyCommandArg),
59+
...buildGlobalFlags(
60+
configs,
61+
auth,
62+
escapeCommandArg,
63+
escapeProxyCommandArg,
64+
escapeProxyCommandPath,
65+
),
5666
];
5767
}
5868

@@ -61,7 +71,7 @@ export function getGlobalFlags(
6171
configs: Pick<WorkspaceConfiguration, "get">,
6272
auth: CliAuth,
6373
): string[] {
64-
return buildGlobalFlags(configs, auth, identity, identity);
74+
return buildGlobalFlags(configs, auth, identity, identity, identity);
6575
}
6676

6777
const identity = (s: string) => s;
@@ -88,6 +98,7 @@ function buildGlobalFlags(
8898
auth: CliAuth,
8999
escUserFlag: (s: string) => string,
90100
escValue: (s: string) => string,
101+
escPath: (s: string) => string,
91102
): string[] {
92103
// Escape after stripping so expansion whitespace stays in one shell token.
93104
// User flags keep shell expansion (e.g. `$VAR`); the auth values and
@@ -97,7 +108,7 @@ function buildGlobalFlags(
97108
auth.store === "extension",
98109
).map(escUserFlag);
99110
if (auth.store === "extension") {
100-
flags.push("--global-config", escValue(auth.configDir));
111+
flags.push("--global-config", escPath(auth.configDir));
101112
}
102113
flags.push("--url", escValue(auth.url));
103114
if (auth.useKeyring !== undefined) {

‎test/unit/common/strings.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import {
55
escapeCommandArg,
66
escapeProxyCommandArg,
7+
escapeProxyCommandPath,
78
escapeShellArg,
89
} from "@/common/strings";
910

@@ -123,3 +124,22 @@ describe("escapeProxyCommandArg", () => {
123124
expect(escapeProxyCommandArg(value)).toBe(escape(value));
124125
});
125126
});
127+
128+
describe("escapeProxyCommandPath", () => {
129+
const platformSpy = vi.spyOn(os, "platform");
130+
afterEach(() => platformSpy.mockReset());
131+
132+
it.each([
133+
[
134+
"win32",
135+
String.raw`C:\Users\John Doe\coder.exe`,
136+
'"C:/Users/John Doe/coder.exe"',
137+
],
138+
["win32", String.raw`d:\a\b`, "d:/a/b"],
139+
["win32", String.raw`\\server\share\x`, String.raw`"\\server\share\x"`],
140+
["linux", String.raw`C:\x`, String.raw`'C:\x'`],
141+
] as const)("on %s writes %j as %s", (platform, value, expected) => {
142+
platformSpy.mockReturnValue(platform);
143+
expect(escapeProxyCommandPath(value)).toBe(expected);
144+
});
145+
});

‎test/unit/remote/remote.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,10 @@ describe("Remote", () => {
403403
// OpenSSH runs the command with `sh -c` on Unix; Win32-OpenSSH passes it
404404
// to CreateProcessW, so the program's argv parser splits it.
405405
it("reaches the CLI unchanged through this platform's OpenSSH", () => {
406-
const configDir = "/Users/me/Library/Application Support/coder";
406+
// A drive path on Windows reaches the CLI with `/`.
407+
const configDir = isWindows()
408+
? String.raw`C:\Users\John Doe\AppData\coder`
409+
: "/Users/me/Library/Application Support/coder";
407410
const headerCommand = 'echo "X: $TOKEN" "C:\\a b\\" `id`';
408411
const pathResolver = new PathResolver("/mock/global", "/mock/log");
409412
new MockConfigurationProvider().set("coder.headerCommand", headerCommand);
@@ -435,7 +438,7 @@ describe("Remote", () => {
435438
expect(JSON.parse(result.stdout)).toStrictEqual([
436439
"--token=",
437440
"--global-config",
438-
configDir,
441+
configDir.replaceAll("\\", "/"),
439442
"--url",
440443
CLI_AUTH.url,
441444
"--header-command",

0 commit comments

Comments
 (0)